mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 15:11:59 +01:00
[boschindego] Implement OAuth2 authorization (#14745)
Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
parent
7a26af7164
commit
20f306f485
@ -4,17 +4,37 @@ This is the Binding for Bosch Indego Connect lawn mowers.
|
||||
Thank´s to zazaz-de who found out how the API works.
|
||||
His [Java Library](https://github.com/zazaz-de/iot-device-bosch-indego-controller) made this Binding possible.
|
||||
|
||||
## Discovery
|
||||
|
||||
When the bridge is authorized, the binding can automatically discover Indego mowers connected to the SingleKey ID account.
|
||||
|
||||
## Thing Configuration
|
||||
|
||||
Currently the binding supports _**indego**_ mowers as a thing type with these configuration parameters:
|
||||
### `account` Bridge Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|--------------------|-------------------------------------------------------------------|---------|
|
||||
| username | Username for the Bosch Indego account | |
|
||||
| password | Password for the Bosch Indego account | |
|
||||
| refresh | The number of seconds between refreshing device state when idle | 180 |
|
||||
| stateActiveRefresh | The number of seconds between refreshing device state when active | 30 |
|
||||
| cuttingTimeRefresh | The number of minutes between refreshing last/next cutting time | 60 |
|
||||
There are no parameters for the bridge.
|
||||
However, the bridge is used for managing the [SingleKey ID](https://singlekey-id.com/) digital identity.
|
||||
|
||||
#### Authorization
|
||||
|
||||
To authorize, please follow these steps:
|
||||
|
||||
- In your browser, go to the [Bosch Indego login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User).
|
||||
- Select "Bosch ID", enter your e-mail address and password and click "Log-in".
|
||||
- In your browser, open Developer Tools.
|
||||
- With developer tools showing on the right, go to [Bosch Indego login page](https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/authorize?redirect_uri=com.bosch.indegoconnect://login&client_id=65bb8c9d-1070-4fb4-aa95-853618acc876&response_type=code&scope=openid%20offline_access%20https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User) again.
|
||||
- "Please wait..." should now be displayed.
|
||||
- Find the `authresp` and copy the code: `com.bosch.indegoconnect://login/?code=<copy this>`
|
||||
- Use the openHAB console to authorize with this code: `openhab:boschindego authorize <paste code>`
|
||||
|
||||
### `indego` Thing Configuration
|
||||
|
||||
| Parameter | Description | Default | Required |
|
||||
|--------------------|-------------------------------------------------------------------|---------|----------|
|
||||
| serialNumber | The serial number of the connected Indego mower | | yes |
|
||||
| refresh | The number of seconds between refreshing device state when idle | 180 | no |
|
||||
| stateActiveRefresh | The number of seconds between refreshing device state when active | 30 | no |
|
||||
| cuttingTimeRefresh | The number of minutes between refreshing last/next cutting time | 60 | no |
|
||||
|
||||
## Channels
|
||||
|
||||
@ -80,26 +100,29 @@ Currently the binding supports _**indego**_ mowers as a thing type with these
|
||||
### `indego.things` File
|
||||
|
||||
```java
|
||||
boschindego:indego:lawnmower [username="mail@example.com", password="idontneedtocutthelawnagain", refresh=120]
|
||||
Bridge boschindego:account:singlekey {
|
||||
Things:
|
||||
Thing indego lawnmower [serialNumber="1234567890", refresh=120]
|
||||
}
|
||||
```
|
||||
|
||||
### `indego.items` File
|
||||
|
||||
```java
|
||||
Number Indego_State { channel="boschindego:indego:lawnmower:state" }
|
||||
Number Indego_ErrorCode { channel="boschindego:indego:lawnmower:errorcode" }
|
||||
Number Indego_StateCode { channel="boschindego:indego:lawnmower:statecode" }
|
||||
String Indego_TextualState { channel="boschindego:indego:lawnmower:textualstate" }
|
||||
Number Indego_Ready { channel="boschindego:indego:lawnmower:ready" }
|
||||
Dimmer Indego_Mowed { channel="boschindego:indego:lawnmower:mowed" }
|
||||
DateTime Indego_LastCutting { channel="boschindego:indego:lawnmower:lastCutting" }
|
||||
DateTime Indego_NextCutting { channel="boschindego:indego:lawnmower:nextCutting" }
|
||||
Number:ElectricPotential Indego_BatteryVoltage { channel="boschindego:indego:lawnmower:batteryVoltage" }
|
||||
Number Indego_BatteryLevel { channel="boschindego:indego:lawnmower:batteryLevel" }
|
||||
Switch Indego_LowBattery { channel="boschindego:indego:lawnmower:lowBattery" }
|
||||
Number:Temperature Indego_BatteryTemperature { channel="boschindego:indego:lawnmower:batteryTemperature" }
|
||||
Number:Area Indego_GardenSize { channel="boschindego:indego:lawnmower:gardenSize" }
|
||||
Image Indego_GardenMap { channel="boschindego:indego:lawnmower:gardenMap" }
|
||||
Number Indego_State { channel="boschindego:indego:singlekey:lawnmower:state" }
|
||||
Number Indego_ErrorCode { channel="boschindego:indego:singlekey:lawnmower:errorcode" }
|
||||
Number Indego_StateCode { channel="boschindego:indego:singlekey:lawnmower:statecode" }
|
||||
String Indego_TextualState { channel="boschindego:indego:singlekey:lawnmower:textualstate" }
|
||||
Number Indego_Ready { channel="boschindego:indego:singlekey:lawnmower:ready" }
|
||||
Dimmer Indego_Mowed { channel="boschindego:indego:singlekey:lawnmower:mowed" }
|
||||
DateTime Indego_LastCutting { channel="boschindego:indego:singlekey:lawnmower:lastCutting" }
|
||||
DateTime Indego_NextCutting { channel="boschindego:indego:singlekey:lawnmower:nextCutting" }
|
||||
Number:ElectricPotential Indego_BatteryVoltage { channel="boschindego:indego:singlekey:lawnmower:batteryVoltage" }
|
||||
Number Indego_BatteryLevel { channel="boschindego:indego:singlekey:lawnmower:batteryLevel" }
|
||||
Switch Indego_LowBattery { channel="boschindego:indego:singlekey:lawnmower:lowBattery" }
|
||||
Number:Temperature Indego_BatteryTemperature { channel="boschindego:indego:singlekey:lawnmower:batteryTemperature" }
|
||||
Number:Area Indego_GardenSize { channel="boschindego:indego:singlekey:lawnmower:gardenSize" }
|
||||
Image Indego_GardenMap { channel="boschindego:indego:singlekey:lawnmower:gardenMap" }
|
||||
```
|
||||
|
||||
### `indego.sitemap` File
|
||||
|
@ -29,6 +29,7 @@ public class BoschIndegoBindingConstants {
|
||||
public static final String BINDING_ID = "boschindego";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
|
||||
public static final ThingTypeUID THING_TYPE_INDEGO = new ThingTypeUID(BINDING_ID, "indego");
|
||||
|
||||
// List of all Channel ids
|
||||
@ -47,5 +48,13 @@ public class BoschIndegoBindingConstants {
|
||||
public static final String GARDEN_SIZE = "gardenSize";
|
||||
public static final String GARDEN_MAP = "gardenMap";
|
||||
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_INDEGO);
|
||||
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_INDEGO);
|
||||
|
||||
// Bosch SingleKey ID OAuth2
|
||||
private static final String BSK_BASE_URI = "https://prodindego.b2clogin.com/prodindego.onmicrosoft.com/b2c_1a_signup_signin/oauth2/v2.0/";
|
||||
public static final String BSK_CLIENT_ID = "65bb8c9d-1070-4fb4-aa95-853618acc876";
|
||||
public static final String BSK_AUTH_URI = BSK_BASE_URI + "authorize";
|
||||
public static final String BSK_TOKEN_URI = BSK_BASE_URI + "token";
|
||||
public static final String BSK_REDIRECT_URI = "com.bosch.indegoconnect://login";
|
||||
public static final String BSK_SCOPE = "openid offline_access https://prodindego.onmicrosoft.com/indego-mobile-api/Indego.Mower.User";
|
||||
}
|
||||
|
@ -12,16 +12,19 @@
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal;
|
||||
|
||||
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.THING_TYPE_INDEGO;
|
||||
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.boschindego.internal.handler.BoschAccountHandler;
|
||||
import org.openhab.binding.boschindego.internal.handler.BoschIndegoHandler;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthFactory;
|
||||
import org.openhab.core.i18n.LocaleProvider;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.i18n.TranslationProvider;
|
||||
import org.openhab.core.io.net.http.HttpClientFactory;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
@ -37,21 +40,25 @@ import org.osgi.service.component.annotations.Reference;
|
||||
* handlers.
|
||||
*
|
||||
* @author Jonas Fleck - Initial contribution
|
||||
* @author Jacob Laursen - Replaced authorization by OAuth2
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.boschindego")
|
||||
public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private final HttpClient httpClient;
|
||||
private final OAuthFactory oAuthFactory;
|
||||
private final BoschIndegoTranslationProvider translationProvider;
|
||||
private final TimeZoneProvider timeZoneProvider;
|
||||
|
||||
@Activate
|
||||
public BoschIndegoHandlerFactory(@Reference HttpClientFactory httpClientFactory,
|
||||
final @Reference TranslationProvider i18nProvider, final @Reference LocaleProvider localeProvider,
|
||||
final @Reference TimeZoneProvider timeZoneProvider, ComponentContext componentContext) {
|
||||
final @Reference OAuthFactory oAuthFactory, final @Reference TranslationProvider i18nProvider,
|
||||
final @Reference LocaleProvider localeProvider, final @Reference TimeZoneProvider timeZoneProvider,
|
||||
ComponentContext componentContext) {
|
||||
super.activate(componentContext);
|
||||
this.httpClient = httpClientFactory.getCommonHttpClient();
|
||||
this.oAuthFactory = oAuthFactory;
|
||||
this.translationProvider = new BoschIndegoTranslationProvider(i18nProvider, localeProvider);
|
||||
this.timeZoneProvider = timeZoneProvider;
|
||||
}
|
||||
@ -65,7 +72,9 @@ public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (THING_TYPE_INDEGO.equals(thingTypeUID)) {
|
||||
if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
|
||||
return new BoschAccountHandler((Bridge) thing, httpClient, oAuthFactory);
|
||||
} else if (THING_TYPE_INDEGO.equals(thingTypeUID)) {
|
||||
return new BoschIndegoHandler(thing, httpClient, translationProvider, timeZoneProvider);
|
||||
}
|
||||
|
||||
|
@ -12,10 +12,11 @@
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
@ -30,25 +31,19 @@ import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
||||
import org.openhab.binding.boschindego.internal.dto.PredictiveAdjustment;
|
||||
import org.openhab.binding.boschindego.internal.dto.PredictiveStatus;
|
||||
import org.openhab.binding.boschindego.internal.dto.request.AuthenticationRequest;
|
||||
import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.AuthenticationResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.DeviceCalendarResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.ErrorResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.Mower;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
|
||||
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthClientService;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthException;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
|
||||
import org.openhab.core.library.types.RawType;
|
||||
import org.osgi.framework.FrameworkUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@ -56,164 +51,82 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.JsonParseException;
|
||||
|
||||
/**
|
||||
* Controller for communicating with a Bosch Indego device through Bosch services.
|
||||
* This class provides methods for retrieving state information as well as controlling
|
||||
* the device.
|
||||
*
|
||||
* The implementation is based on zazaz-de/iot-device-bosch-indego-controller, but
|
||||
* rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for
|
||||
* JSON parsing. Thanks to Oliver Schünemann for providing the original implementation.
|
||||
* Controller for communicating with a Bosch Indego services.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IndegoController {
|
||||
|
||||
private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/";
|
||||
private static final URI BASE_URI = URI.create(BASE_URL);
|
||||
private static final String SERIAL_NUMBER_SUBPATH = "alms/";
|
||||
private static final String SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO";
|
||||
private static final String CONTEXT_HEADER_NAME = "x-im-context-id";
|
||||
protected static final String SERIAL_NUMBER_SUBPATH = "alms/";
|
||||
|
||||
private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/";
|
||||
private static final String CONTENT_TYPE_HEADER = "application/json";
|
||||
|
||||
private static final String BEARER = "Bearer ";
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
|
||||
private final String basicAuthenticationHeader;
|
||||
private final Gson gson = new Gson();
|
||||
private final HttpClient httpClient;
|
||||
|
||||
private IndegoSession session = new IndegoSession();
|
||||
private final OAuthClientService oAuthClientService;
|
||||
private final String userAgent;
|
||||
|
||||
/**
|
||||
* Initialize the controller instance.
|
||||
*
|
||||
* @param username the username for authenticating
|
||||
* @param password the password
|
||||
* @param httpClient the HttpClient for communicating with the service
|
||||
* @param oAuthClientService the OAuthClientService for authorization
|
||||
*/
|
||||
public IndegoController(HttpClient httpClient, String username, String password) {
|
||||
public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientService) {
|
||||
this.httpClient = httpClient;
|
||||
basicAuthenticationHeader = "Basic "
|
||||
+ Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
|
||||
this.oAuthClientService = oAuthClientService;
|
||||
userAgent = "openHAB " + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with server and store session context and serial number.
|
||||
*
|
||||
* Gets serial numbers of all the associated Indego devices.
|
||||
*
|
||||
* @return the serial numbers of the devices
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private void authenticate() throws IndegoAuthenticationException, IndegoException {
|
||||
int status = 0;
|
||||
public Collection<String> getSerialNumbers() throws IndegoAuthenticationException, IndegoException {
|
||||
Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class);
|
||||
|
||||
return Arrays.stream(mowers).map(m -> m.serialNumber).toList();
|
||||
}
|
||||
|
||||
private String getAuthorizationUrl() {
|
||||
try {
|
||||
Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
|
||||
.header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
|
||||
|
||||
AuthenticationRequest authRequest = new AuthenticationRequest();
|
||||
authRequest.device = "";
|
||||
authRequest.osType = "Android";
|
||||
authRequest.osVersion = "4.0";
|
||||
authRequest.deviceManufacturer = "unknown";
|
||||
authRequest.deviceType = "unknown";
|
||||
String json = gson.toJson(authRequest);
|
||||
request.content(new StringContentProvider(json));
|
||||
request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
|
||||
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("POST request for {}", BASE_URL + "authenticate");
|
||||
}
|
||||
|
||||
ContentResponse response = sendRequest(request);
|
||||
status = response.getStatus();
|
||||
if (status == HttpStatus.UNAUTHORIZED_401) {
|
||||
throw new IndegoAuthenticationException("Authentication was rejected");
|
||||
}
|
||||
if (!HttpStatus.isSuccess(status)) {
|
||||
throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
|
||||
}
|
||||
|
||||
String jsonResponse = response.getContentAsString();
|
||||
if (jsonResponse.isEmpty()) {
|
||||
throw new IndegoInvalidResponseException("No content returned", status);
|
||||
}
|
||||
logger.trace("JSON response: '{}'", jsonResponse);
|
||||
|
||||
AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
|
||||
if (authenticationResponse == null) {
|
||||
throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse",
|
||||
status);
|
||||
}
|
||||
session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
|
||||
getContextExpirationTimeFromCookie());
|
||||
logger.debug("Initialized session {}", session);
|
||||
} catch (JsonParseException e) {
|
||||
throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e, status);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IndegoException(e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new IndegoException(e);
|
||||
return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null);
|
||||
} catch (OAuthException e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context expiration time as a calculated {@link Instant} relative to now.
|
||||
* The information is obtained from max age in the Bosch Indego SSO cookie.
|
||||
* Please note that this cookie is only sent initially when authenticating, so
|
||||
* the value will not be subject to any updates.
|
||||
*
|
||||
* @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
|
||||
*/
|
||||
private Instant getContextExpirationTimeFromCookie() {
|
||||
return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
|
||||
.findFirst().map(c -> {
|
||||
return Instant.now().plusSeconds(c.getMaxAge());
|
||||
}).orElseGet(() -> {
|
||||
return Instant.MIN;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deauthenticate session. This method should be called as part of cleanup to reduce
|
||||
* lingering sessions. This can potentially avoid killed sessions in situation with
|
||||
* multiple clients (e.g. openHAB and mobile app) if restrictions on concurrent
|
||||
* number of sessions would be put on the service.
|
||||
*
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void deauthenticate() throws IndegoException {
|
||||
if (session.isValid()) {
|
||||
deleteRequest("authenticate");
|
||||
session.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps {@link #getRequest(String, Class)} into an authenticated session.
|
||||
*
|
||||
* @param path the relative path to which the request should be sent
|
||||
* @param dtoClass the DTO class to which the JSON result should be deserialized
|
||||
* @return the deserialized DTO from the JSON response
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
|
||||
throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
|
||||
if (!session.isValid()) {
|
||||
authenticate();
|
||||
}
|
||||
private String getAuthorizationHeader() throws IndegoException {
|
||||
final AccessTokenResponse accessTokenResponse;
|
||||
try {
|
||||
logger.debug("Session {} valid, skipping authentication", session);
|
||||
return getRequest(path, dtoClass);
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Context rejected", e);
|
||||
} else {
|
||||
logger.debug("Context rejected: {}", e.getMessage());
|
||||
}
|
||||
session.invalidate();
|
||||
authenticate();
|
||||
return getRequest(path, dtoClass);
|
||||
accessTokenResponse = oAuthClientService.getAccessTokenResponse();
|
||||
} catch (OAuthException | OAuthResponseException e) {
|
||||
logger.debug("Error fetching access token: {}", e.getMessage(), e);
|
||||
throw new IndegoAuthenticationException(
|
||||
"Error fetching access token. Invalid authcode? Please generate a new one -> "
|
||||
+ getAuthorizationUrl(),
|
||||
e);
|
||||
} catch (IOException e) {
|
||||
throw new IndegoException("An unexpected IOException occurred: " + e.getMessage(), e);
|
||||
}
|
||||
if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null
|
||||
|| accessTokenResponse.getAccessToken().isEmpty()) {
|
||||
throw new IndegoAuthenticationException(
|
||||
"No access token. Is this thing authorized? -> " + getAuthorizationUrl());
|
||||
}
|
||||
if (accessTokenResponse.getRefreshToken() == null || accessTokenResponse.getRefreshToken().isEmpty()) {
|
||||
throw new IndegoAuthenticationException("No refresh token. Please reauthorize -> " + getAuthorizationUrl());
|
||||
}
|
||||
|
||||
return BEARER + accessTokenResponse.getAccessToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -226,12 +139,12 @@ public class IndegoController {
|
||||
* @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private <T> T getRequest(String path, Class<? extends T> dtoClass)
|
||||
protected <T> T getRequest(String path, Class<? extends T> dtoClass)
|
||||
throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
|
||||
int status = 0;
|
||||
try {
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
|
||||
session.getContextId());
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
|
||||
.header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("GET request for {}", BASE_URL + path);
|
||||
}
|
||||
@ -243,7 +156,7 @@ public class IndegoController {
|
||||
}
|
||||
if (status == HttpStatus.UNAUTHORIZED_401) {
|
||||
// This will currently not happen because "WWW-Authenticate" header is missing; see below.
|
||||
throw new IndegoAuthenticationException("Context rejected");
|
||||
throw new IndegoAuthenticationException("Unauthorized");
|
||||
}
|
||||
if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
|
||||
throw new IndegoTimeoutException("Gateway timeout");
|
||||
@ -274,45 +187,17 @@ public class IndegoController {
|
||||
Response response = ((HttpResponseException) cause).getResponse();
|
||||
if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
|
||||
/*
|
||||
* When contextId is not valid, the service will respond with HTTP code 401 without
|
||||
* any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
|
||||
* HttpResponseException. We need to handle this in order to attempt
|
||||
* reauthentication.
|
||||
* The service may respond with HTTP code 401 without any "WWW-Authenticate"
|
||||
* header, violating RFC 7235. Jetty will then throw HttpResponseException.
|
||||
* We need to handle this in order to attempt reauthentication.
|
||||
*/
|
||||
throw new IndegoAuthenticationException("Context rejected", e);
|
||||
throw new IndegoAuthenticationException("Unauthorized", e);
|
||||
}
|
||||
}
|
||||
throw new IndegoException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps {@link #getRawRequest(String)} into an authenticated session.
|
||||
*
|
||||
* @param path the relative path to which the request should be sent
|
||||
* @return the raw data from the response
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
|
||||
if (!session.isValid()) {
|
||||
authenticate();
|
||||
}
|
||||
try {
|
||||
logger.debug("Session {} valid, skipping authentication", session);
|
||||
return getRawRequest(path);
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Context rejected", e);
|
||||
} else {
|
||||
logger.debug("Context rejected: {}", e.getMessage());
|
||||
}
|
||||
session.invalidate();
|
||||
authenticate();
|
||||
return getRawRequest(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a GET request to the server and returns the raw response.
|
||||
*
|
||||
@ -321,11 +206,11 @@ public class IndegoController {
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
|
||||
protected RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
|
||||
int status = 0;
|
||||
try {
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
|
||||
session.getContextId());
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
|
||||
.header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("GET request for {}", BASE_URL + path);
|
||||
}
|
||||
@ -382,24 +267,9 @@ public class IndegoController {
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private void putRequestWithAuthentication(String path, Object requestDto)
|
||||
protected void putRequestWithAuthentication(String path, Object requestDto)
|
||||
throws IndegoAuthenticationException, IndegoException {
|
||||
if (!session.isValid()) {
|
||||
authenticate();
|
||||
}
|
||||
try {
|
||||
logger.debug("Session {} valid, skipping authentication", session);
|
||||
putPostRequest(HttpMethod.PUT, path, requestDto);
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Context rejected", e);
|
||||
} else {
|
||||
logger.debug("Context rejected: {}", e.getMessage());
|
||||
}
|
||||
session.invalidate();
|
||||
authenticate();
|
||||
putPostRequest(HttpMethod.PUT, path, requestDto);
|
||||
}
|
||||
putPostRequest(HttpMethod.PUT, path, requestDto);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -409,23 +279,8 @@ public class IndegoController {
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private void postRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
|
||||
if (!session.isValid()) {
|
||||
authenticate();
|
||||
}
|
||||
try {
|
||||
logger.debug("Session {} valid, skipping authentication", session);
|
||||
putPostRequest(HttpMethod.POST, path, null);
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Context rejected", e);
|
||||
} else {
|
||||
logger.debug("Context rejected: {}", e.getMessage());
|
||||
}
|
||||
session.invalidate();
|
||||
authenticate();
|
||||
putPostRequest(HttpMethod.POST, path, null);
|
||||
}
|
||||
protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException {
|
||||
putPostRequest(HttpMethod.POST, path, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -437,12 +292,12 @@ public class IndegoController {
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
|
||||
protected void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
|
||||
throws IndegoAuthenticationException, IndegoException {
|
||||
try {
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(method)
|
||||
.header(CONTEXT_HEADER_NAME, session.getContextId())
|
||||
.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
|
||||
.header(HttpHeader.AUTHORIZATION, getAuthorizationHeader())
|
||||
.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent);
|
||||
if (requestDto != null) {
|
||||
String payload = gson.toJson(requestDto);
|
||||
request.content(new StringContentProvider(payload));
|
||||
@ -502,32 +357,6 @@ public class IndegoController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a DELETE request to the server.
|
||||
*
|
||||
* @param path the relative path to which the request should be sent
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
private void deleteRequest(String path) throws IndegoException {
|
||||
try {
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.DELETE)
|
||||
.header(CONTEXT_HEADER_NAME, session.getContextId());
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("DELETE request for {}", BASE_URL + path);
|
||||
}
|
||||
ContentResponse response = sendRequest(request);
|
||||
int status = response.getStatus();
|
||||
if (!HttpStatus.isSuccess(status)) {
|
||||
throw new IndegoException("The request failed with error: " + status);
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IndegoException(e);
|
||||
} catch (TimeoutException | ExecutionException e) {
|
||||
throw new IndegoException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send request. This method exists for the purpose of avoiding multiple calls to
|
||||
* the server at the same time.
|
||||
@ -538,245 +367,8 @@ public class IndegoController {
|
||||
* @throws TimeoutException if send times out
|
||||
* @throws ExecutionException if execution fails
|
||||
*/
|
||||
private synchronized ContentResponse sendRequest(Request request)
|
||||
protected synchronized ContentResponse sendRequest(Request request)
|
||||
throws InterruptedException, TimeoutException, ExecutionException {
|
||||
return request.send();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets serial number of the associated Indego device
|
||||
*
|
||||
* @return the serial number of the device
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public synchronized String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
|
||||
if (!session.isInitialized()) {
|
||||
logger.debug("Session not yet initialized when serial number was requested; authenticating...");
|
||||
authenticate();
|
||||
}
|
||||
return session.getSerialNumber();
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the device state from the server.
|
||||
*
|
||||
* @return the device state
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
|
||||
DeviceStateResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the device state from the server. This overload will return when the state
|
||||
* has changed, or the timeout has been reached.
|
||||
*
|
||||
* @param timeout Maximum time to wait for response
|
||||
* @return the device state
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceStateResponse getState(Duration timeout) throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequestWithAuthentication(
|
||||
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state?longpoll=true&timeout=" + timeout.getSeconds(),
|
||||
DeviceStateResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the device operating data from the server.
|
||||
* Server will request this directly from the device, so operation might be slow.
|
||||
*
|
||||
* @return the device state
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public OperatingDataResponse getOperatingData()
|
||||
throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
|
||||
return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData",
|
||||
OperatingDataResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the map generated by the device from the server.
|
||||
*
|
||||
* @return the garden map
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public RawType getMap() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map");
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the calendar.
|
||||
*
|
||||
* @return the calendar
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
|
||||
DeviceCalendarResponse calendar = getRequestWithAuthentication(
|
||||
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a command to the Indego device.
|
||||
*
|
||||
* @param command the control command to send to the device
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoInvalidCommandException if the command was not processed correctly
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void sendCommand(DeviceCommand command)
|
||||
throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
|
||||
SetStateRequest request = new SetStateRequest();
|
||||
request.state = command.getActionCode();
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the predictive weather forecast.
|
||||
*
|
||||
* @return the weather forecast DTO
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
|
||||
LocationWeatherResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the predictive adjustment.
|
||||
*
|
||||
* @return the predictive adjustment
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequestWithAuthentication(
|
||||
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
|
||||
PredictiveAdjustment.class).adjustment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the predictive adjustment.
|
||||
*
|
||||
* @param adjust the predictive adjustment
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
|
||||
final PredictiveAdjustment adjustment = new PredictiveAdjustment();
|
||||
adjustment.adjustment = adjust;
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
|
||||
adjustment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive moving.
|
||||
*
|
||||
* @return predictive moving
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
|
||||
PredictiveStatus.class).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets predictive moving.
|
||||
*
|
||||
* @param enable
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
|
||||
final PredictiveStatus status = new PredictiveStatus();
|
||||
status.enabled = enable;
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive last cutting as {@link Instant}.
|
||||
*
|
||||
* @return predictive last cutting
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
|
||||
try {
|
||||
return getRequestWithAuthentication(
|
||||
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
|
||||
PredictiveLastCuttingResponse.class).getLastCutting();
|
||||
} catch (IndegoInvalidResponseException e) {
|
||||
if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive next cutting as {@link Instant}.
|
||||
*
|
||||
* @return predictive next cutting
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
|
||||
try {
|
||||
return getRequestWithAuthentication(
|
||||
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
|
||||
PredictiveNextCuttingResponse.class).getNextCutting();
|
||||
} catch (IndegoInvalidResponseException e) {
|
||||
if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive exclusion time.
|
||||
*
|
||||
* @return predictive exclusion time DTO
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
|
||||
DeviceCalendarResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets predictive exclusion time.
|
||||
*
|
||||
* @param calendar calendar DTO
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
|
||||
throws IndegoAuthenticationException, IndegoException {
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request map position updates for the next ({@link count} * {@link interval}) number of seconds.
|
||||
*
|
||||
* @param count Number of updates
|
||||
* @param interval Number of seconds between updates
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void requestPosition(int count, int interval) throws IndegoAuthenticationException, IndegoException {
|
||||
postRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/requestPosition?count=" + count
|
||||
+ "&interval=" + interval);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
||||
import org.openhab.binding.boschindego.internal.dto.PredictiveAdjustment;
|
||||
import org.openhab.binding.boschindego.internal.dto.PredictiveStatus;
|
||||
import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.DeviceCalendarResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthClientService;
|
||||
import org.openhab.core.library.types.RawType;
|
||||
|
||||
/**
|
||||
* Controller for communicating with a Bosch Indego device through Bosch services.
|
||||
* This class provides methods for retrieving state information as well as controlling
|
||||
* the device.
|
||||
*
|
||||
* The implementation is based on zazaz-de's iot-device-bosch-indego-controller, but
|
||||
* rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for
|
||||
* JSON parsing. Thanks to Oliver Schünemann for providing the original implementation.
|
||||
*
|
||||
* @see <a href=
|
||||
* "https://github.com/zazaz-de/iot-device-bosch-indego-controller">zazaz-de/iot-device-bosch-indego-controller</a>
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IndegoDeviceController extends IndegoController {
|
||||
|
||||
private String serialNumber;
|
||||
|
||||
/**
|
||||
* Initialize the controller instance.
|
||||
*
|
||||
* @param httpClient the HttpClient for communicating with the service
|
||||
* @param oAuthClientService the OAuthClientService for authorization
|
||||
* @param serialNumber the serial number of the device instance
|
||||
*/
|
||||
public IndegoDeviceController(HttpClient httpClient, OAuthClientService oAuthClientService, String serialNumber) {
|
||||
super(httpClient, oAuthClientService);
|
||||
if (serialNumber.isBlank()) {
|
||||
throw new IllegalArgumentException("Serial number must be provided");
|
||||
}
|
||||
this.serialNumber = serialNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the device state from the server.
|
||||
*
|
||||
* @return the device state
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/state", DeviceStateResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the device state from the server. This overload will return when the state
|
||||
* has changed, or the timeout has been reached.
|
||||
*
|
||||
* @param timeout maximum time to wait for response
|
||||
* @return the device state
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceStateResponse getState(Duration timeout) throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/state?longpoll=true&timeout=" + timeout.getSeconds(),
|
||||
DeviceStateResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the device operating data from the server.
|
||||
* Server will request this directly from the device, so operation might be slow.
|
||||
*
|
||||
* @return the device state
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public OperatingDataResponse getOperatingData()
|
||||
throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/operatingData", OperatingDataResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the map generated by the device from the server.
|
||||
*
|
||||
* @return the garden map
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public RawType getMap() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRawRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/map");
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the calendar.
|
||||
*
|
||||
* @return the calendar
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
|
||||
DeviceCalendarResponse calendar = getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/calendar",
|
||||
DeviceCalendarResponse.class);
|
||||
return calendar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a command to the Indego device.
|
||||
*
|
||||
* @param command the control command to send to the device
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoInvalidCommandException if the command was not processed correctly
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void sendCommand(DeviceCommand command)
|
||||
throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
|
||||
SetStateRequest request = new SetStateRequest();
|
||||
request.state = command.getActionCode();
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/state", request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the predictive weather forecast.
|
||||
*
|
||||
* @return the weather forecast DTO
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/weather", LocationWeatherResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the predictive adjustment.
|
||||
*
|
||||
* @return the predictive adjustment
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/useradjustment",
|
||||
PredictiveAdjustment.class).adjustment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the predictive adjustment.
|
||||
*
|
||||
* @param adjust the predictive adjustment
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
|
||||
final PredictiveAdjustment adjustment = new PredictiveAdjustment();
|
||||
adjustment.adjustment = adjust;
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/useradjustment", adjustment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive moving.
|
||||
*
|
||||
* @return predictive moving
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive", PredictiveStatus.class).enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets predictive moving.
|
||||
*
|
||||
* @param enable
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
|
||||
final PredictiveStatus status = new PredictiveStatus();
|
||||
status.enabled = enable;
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive", status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive last cutting as {@link Instant}.
|
||||
*
|
||||
* @return predictive last cutting
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
|
||||
try {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/lastcutting",
|
||||
PredictiveLastCuttingResponse.class).getLastCutting();
|
||||
} catch (IndegoInvalidResponseException e) {
|
||||
if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive next cutting as {@link Instant}.
|
||||
*
|
||||
* @return predictive next cutting
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
|
||||
try {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/nextcutting",
|
||||
PredictiveNextCuttingResponse.class).getNextCutting();
|
||||
} catch (IndegoInvalidResponseException e) {
|
||||
if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries predictive exclusion time.
|
||||
*
|
||||
* @return predictive exclusion time DTO
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
|
||||
return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/calendar", DeviceCalendarResponse.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets predictive exclusion time.
|
||||
*
|
||||
* @param calendar calendar DTO
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
|
||||
throws IndegoAuthenticationException, IndegoException {
|
||||
putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + serialNumber + "/predictive/calendar", calendar);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request map position updates for the next ({@link count} * {@link interval}) number of seconds.
|
||||
*
|
||||
* @param count number of updates
|
||||
* @param interval number of seconds between updates
|
||||
* @throws IndegoAuthenticationException if request was rejected as unauthorized
|
||||
* @throws IndegoException if any communication or parsing error occurred
|
||||
*/
|
||||
public void requestPosition(int count, int interval) throws IndegoAuthenticationException, IndegoException {
|
||||
postRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/requestPosition?count=" + count + "&interval=" + interval);
|
||||
}
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* Session for storing Bosch Indego context information.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IndegoSession {
|
||||
|
||||
private static final Duration DEFAULT_EXPIRATION_PERIOD = Duration.ofSeconds(10);
|
||||
|
||||
private String contextId;
|
||||
private String serialNumber;
|
||||
private Instant expirationTime;
|
||||
|
||||
public IndegoSession() {
|
||||
this("", "", Instant.MIN);
|
||||
}
|
||||
|
||||
public IndegoSession(String contextId, String serialNumber, Instant expirationTime) {
|
||||
this.contextId = contextId;
|
||||
this.serialNumber = serialNumber;
|
||||
this.expirationTime = expirationTime.equals(Instant.MIN) ? Instant.now().plus(DEFAULT_EXPIRATION_PERIOD)
|
||||
: expirationTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context id for HTTP requests (headers "x-im-context-id: <contextId>" and
|
||||
* "Cookie: BOSCH_INDEGO_SSO=<contextId>").
|
||||
*
|
||||
* @return current context id
|
||||
*/
|
||||
public String getContextId() {
|
||||
return contextId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get serial number of device.
|
||||
*
|
||||
* @return serial number
|
||||
*/
|
||||
public String getSerialNumber() {
|
||||
return serialNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiration time of session as {@link Instant}.
|
||||
*
|
||||
* @return expiration time
|
||||
*/
|
||||
public Instant getExpirationTime() {
|
||||
return expirationTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is initialized, i.e. has serial number.
|
||||
*
|
||||
* @see #isValid()
|
||||
* @return true if session is initialized
|
||||
*/
|
||||
public boolean isInitialized() {
|
||||
return !serialNumber.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session is valid, i.e. has not yet expired.
|
||||
*
|
||||
* @return true if session is still valid
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return !contextId.isEmpty() && expirationTime.isAfter(Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate session.
|
||||
*/
|
||||
public void invalidate() {
|
||||
contextId = "";
|
||||
expirationTime = Instant.MIN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("%s (serialNumber %s, expirationTime %s)", contextId, serialNumber, expirationTime);
|
||||
}
|
||||
}
|
@ -13,7 +13,6 @@
|
||||
package org.openhab.binding.boschindego.internal.config;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
|
||||
/**
|
||||
* Configuration for the Bosch Indego thing.
|
||||
@ -22,8 +21,7 @@ import org.eclipse.jdt.annotation.Nullable;
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BoschIndegoConfiguration {
|
||||
public @Nullable String username;
|
||||
public @Nullable String password;
|
||||
public String serialNumber = "";
|
||||
public long refresh = 180;
|
||||
public long stateActiveRefresh = 30;
|
||||
public long cuttingTimeRefresh = 60;
|
||||
|
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal.console;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
|
||||
import org.openhab.binding.boschindego.internal.handler.BoschAccountHandler;
|
||||
import org.openhab.core.io.console.Console;
|
||||
import org.openhab.core.io.console.ConsoleCommandCompleter;
|
||||
import org.openhab.core.io.console.StringsCompleter;
|
||||
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
|
||||
import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingRegistry;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.osgi.service.component.annotations.Activate;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Reference;
|
||||
|
||||
/**
|
||||
* The {@link BoschIndegoCommandExtension} is responsible for handling console commands
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
@Component(service = ConsoleCommandExtension.class)
|
||||
public class BoschIndegoCommandExtension extends AbstractConsoleCommandExtension implements ConsoleCommandCompleter {
|
||||
|
||||
private static final String AUTHORIZE = "authorize";
|
||||
private static final StringsCompleter SUBCMD_COMPLETER = new StringsCompleter(List.of(AUTHORIZE), false);
|
||||
|
||||
private final ThingRegistry thingRegistry;
|
||||
|
||||
@Activate
|
||||
public BoschIndegoCommandExtension(final @Reference ThingRegistry thingRegistry) {
|
||||
super(BoschIndegoBindingConstants.BINDING_ID, "Interact with the Bosch Indego binding.");
|
||||
this.thingRegistry = thingRegistry;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(String[] args, Console console) {
|
||||
if (args.length != 2 || !AUTHORIZE.equals(args[0])) {
|
||||
printUsage(console);
|
||||
return;
|
||||
}
|
||||
|
||||
for (Thing thing : thingRegistry.getAll()) {
|
||||
ThingHandler thingHandler = thing.getHandler();
|
||||
if (thingHandler instanceof BoschAccountHandler accountHandler) {
|
||||
try {
|
||||
accountHandler.authorize(args[1]);
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
console.println("Authorization error: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> getUsages() {
|
||||
return Arrays.asList(buildCommandUsage(AUTHORIZE, "authorize by authorization code"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ConsoleCommandCompleter getCompleter() {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean complete(String[] args, int cursorArgumentIndex, int cursorPosition, List<String> candidates) {
|
||||
if (cursorArgumentIndex <= 0) {
|
||||
return SUBCMD_COMPLETER.complete(args, cursorArgumentIndex, cursorPosition, candidates);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal.discovery;
|
||||
|
||||
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
||||
import org.openhab.binding.boschindego.internal.handler.BoschAccountHandler;
|
||||
import org.openhab.core.config.discovery.AbstractDiscoveryService;
|
||||
import org.openhab.core.config.discovery.DiscoveryResult;
|
||||
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link IndegoDiscoveryService} is responsible for discovering Indego mowers.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IndegoDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
|
||||
|
||||
private static final int TIMEOUT_SECONDS = 60;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(IndegoDiscoveryService.class);
|
||||
|
||||
private @NonNullByDefault({}) BoschAccountHandler accountHandler;
|
||||
|
||||
public IndegoDiscoveryService() {
|
||||
super(Set.of(THING_TYPE_ACCOUNT), TIMEOUT_SECONDS, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @Nullable ThingHandler getThingHandler() {
|
||||
return accountHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setThingHandler(ThingHandler handler) {
|
||||
if (handler instanceof BoschAccountHandler accountHandler) {
|
||||
this.accountHandler = accountHandler;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<ThingTypeUID> getSupportedThingTypes() {
|
||||
return Set.of(THING_TYPE_INDEGO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startScan() {
|
||||
try {
|
||||
Collection<String> serialNumbers = accountHandler.getSerialNumbers();
|
||||
|
||||
ThingUID bridgeUID = accountHandler.getThing().getUID();
|
||||
for (String serialNumber : serialNumbers) {
|
||||
ThingUID thingUID = new ThingUID(THING_TYPE_INDEGO, bridgeUID, serialNumber);
|
||||
DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID)
|
||||
.withProperty(Thing.PROPERTY_SERIAL_NUMBER, serialNumber).withBridge(bridgeUID)
|
||||
.withRepresentationProperty(Thing.PROPERTY_SERIAL_NUMBER)
|
||||
.withLabel("Indego (" + serialNumber + ")").build();
|
||||
|
||||
thingDiscovered(discoveryResult);
|
||||
}
|
||||
} catch (IndegoException e) {
|
||||
logger.debug("Failed to retrieve serial numbers: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected synchronized void stopScan() {
|
||||
super.stopScan();
|
||||
removeOlderResults(getTimestampOfLastScan());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deactivate() {
|
||||
removeOlderResults(Instant.now().getEpochSecond());
|
||||
}
|
||||
}
|
@ -15,16 +15,15 @@ package org.openhab.binding.boschindego.internal.dto.response;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* Response from authenticating with server.
|
||||
* Mower serial number and status.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
public class AuthenticationResponse {
|
||||
|
||||
public String contextId;
|
||||
|
||||
public String userId;
|
||||
public class Mower {
|
||||
|
||||
@SerializedName("alm_sn")
|
||||
public String serialNumber;
|
||||
|
||||
@SerializedName("alm_status")
|
||||
public int status;
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2023 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.boschindego.internal.handler;
|
||||
|
||||
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.boschindego.internal.IndegoController;
|
||||
import org.openhab.binding.boschindego.internal.discovery.IndegoDiscoveryService;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
||||
import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthClientService;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthException;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthFactory;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthResponseException;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerService;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link BoschAccountHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class BoschAccountHandler extends BaseBridgeHandler {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(BoschAccountHandler.class);
|
||||
private final OAuthFactory oAuthFactory;
|
||||
|
||||
private OAuthClientService oAuthClientService;
|
||||
private IndegoController controller;
|
||||
|
||||
public BoschAccountHandler(Bridge bridge, HttpClient httpClient, OAuthFactory oAuthFactory) {
|
||||
super(bridge);
|
||||
|
||||
this.oAuthFactory = oAuthFactory;
|
||||
|
||||
oAuthClientService = oAuthFactory.createOAuthClientService(getThing().getUID().getAsString(), BSK_TOKEN_URI,
|
||||
BSK_AUTH_URI, BSK_CLIENT_ID, null, BSK_SCOPE, false);
|
||||
controller = new IndegoController(httpClient, oAuthClientService);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
|
||||
scheduler.execute(() -> {
|
||||
try {
|
||||
AccessTokenResponse accessTokenResponse = this.oAuthClientService.getAccessTokenResponse();
|
||||
if (accessTokenResponse == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error.oauth2-unauthorized");
|
||||
} else {
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}
|
||||
} catch (OAuthException | OAuthResponseException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error.oauth2-unauthorized");
|
||||
} catch (IOException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
|
||||
"@text/offline.comm-error.oauth2-authorization-failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
oAuthFactory.ungetOAuthService(this.getThing().getUID().getAsString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<Class<? extends ThingHandlerService>> getServices() {
|
||||
return List.of(IndegoDiscoveryService.class);
|
||||
}
|
||||
|
||||
public void authorize(String authCode) throws IndegoAuthenticationException {
|
||||
logger.info("Attempting to authorize using authorization code");
|
||||
|
||||
try {
|
||||
oAuthClientService.getAccessTokenResponseByAuthorizationCode(authCode, BSK_REDIRECT_URI);
|
||||
} catch (OAuthException | OAuthResponseException | IOException e) {
|
||||
throw new IndegoAuthenticationException("Failed to authorize by authorization code " + authCode, e);
|
||||
}
|
||||
|
||||
logger.info("Authorization completed successfully");
|
||||
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}
|
||||
|
||||
public OAuthClientService getOAuthClientService() {
|
||||
return oAuthClientService;
|
||||
}
|
||||
|
||||
public Collection<String> getSerialNumbers() throws IndegoException {
|
||||
return controller.getSerialNumbers();
|
||||
}
|
||||
}
|
@ -28,7 +28,7 @@ import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.client.HttpClient;
|
||||
import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider;
|
||||
import org.openhab.binding.boschindego.internal.DeviceStatus;
|
||||
import org.openhab.binding.boschindego.internal.IndegoController;
|
||||
import org.openhab.binding.boschindego.internal.IndegoDeviceController;
|
||||
import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration;
|
||||
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
||||
import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
|
||||
@ -37,6 +37,7 @@ import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationE
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
|
||||
import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
|
||||
import org.openhab.core.auth.client.oauth2.OAuthClientService;
|
||||
import org.openhab.core.i18n.TimeZoneProvider;
|
||||
import org.openhab.core.library.types.DateTimeType;
|
||||
import org.openhab.core.library.types.DecimalType;
|
||||
@ -47,11 +48,14 @@ import org.openhab.core.library.types.RawType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.binding.BaseThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
@ -84,11 +88,11 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
private final BoschIndegoTranslationProvider translationProvider;
|
||||
private final TimeZoneProvider timeZoneProvider;
|
||||
|
||||
private @NonNullByDefault({}) IndegoController controller;
|
||||
private @NonNullByDefault({}) OAuthClientService oAuthClientService;
|
||||
private @NonNullByDefault({}) IndegoDeviceController controller;
|
||||
private @Nullable ScheduledFuture<?> statePollFuture;
|
||||
private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
|
||||
private @Nullable ScheduledFuture<?> cuttingTimeFuture;
|
||||
private boolean propertiesInitialized;
|
||||
private Optional<Integer> previousStateCode = Optional.empty();
|
||||
private @Nullable RawType cachedMap;
|
||||
private Instant cachedMapTimestamp = Instant.MIN;
|
||||
@ -109,41 +113,56 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
logger.debug("Initializing Indego handler");
|
||||
BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
|
||||
stateInactiveRefreshIntervalSeconds = (int) config.refresh;
|
||||
stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh;
|
||||
String username = config.username;
|
||||
String password = config.password;
|
||||
|
||||
if (username == null || username.isBlank()) {
|
||||
Bridge bridge = getBridge();
|
||||
if (bridge == null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error.missing-username");
|
||||
return;
|
||||
}
|
||||
if (password == null || password.isBlank()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error.missing-password");
|
||||
"@text/offline.conf-error.missing-bridge");
|
||||
return;
|
||||
}
|
||||
|
||||
controller = new IndegoController(httpClient, username, password);
|
||||
ThingHandler handler = bridge.getHandler();
|
||||
if (handler instanceof BoschAccountHandler accountHandler) {
|
||||
this.oAuthClientService = accountHandler.getOAuthClientService();
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error.missing-bridge");
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateProperty(Thing.PROPERTY_SERIAL_NUMBER, config.serialNumber);
|
||||
|
||||
controller = new IndegoDeviceController(httpClient, oAuthClientService, config.serialNumber);
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
previousStateCode = Optional.empty();
|
||||
rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds);
|
||||
rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, false);
|
||||
this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
|
||||
config.cuttingTimeRefresh, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds) {
|
||||
@Override
|
||||
public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
|
||||
if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE
|
||||
&& getThing().getStatusInfo().getStatus() == ThingStatus.OFFLINE) {
|
||||
// Trigger immediate state refresh upon authorization success.
|
||||
rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, true);
|
||||
} else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds, boolean force) {
|
||||
ScheduledFuture<?> statePollFuture = this.statePollFuture;
|
||||
if (statePollFuture != null) {
|
||||
if (refreshIntervalSeconds == currentRefreshIntervalSeconds) {
|
||||
if (!force && refreshIntervalSeconds == currentRefreshIntervalSeconds) {
|
||||
// No change.
|
||||
return false;
|
||||
}
|
||||
statePollFuture.cancel(false);
|
||||
statePollFuture.cancel(force);
|
||||
}
|
||||
logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
|
||||
delaySeconds);
|
||||
@ -156,7 +175,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
logger.debug("Disposing Indego handler");
|
||||
ScheduledFuture<?> pollFuture = this.statePollFuture;
|
||||
if (pollFuture != null) {
|
||||
pollFuture.cancel(true);
|
||||
@ -172,14 +190,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
pollFuture.cancel(true);
|
||||
}
|
||||
this.cuttingTimeFuture = null;
|
||||
|
||||
scheduler.execute(() -> {
|
||||
try {
|
||||
controller.deauthenticate();
|
||||
} catch (IndegoException e) {
|
||||
logger.debug("Deauthentication failed", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -280,6 +290,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
try {
|
||||
refreshState();
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
logger.warn("Failed to authenticate: {}", e.getMessage());
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.comm-error.authentication-failure");
|
||||
} catch (IndegoTimeoutException e) {
|
||||
@ -291,11 +302,6 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
}
|
||||
|
||||
private void refreshState() throws IndegoAuthenticationException, IndegoException {
|
||||
if (!propertiesInitialized) {
|
||||
getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
|
||||
propertiesInitialized = true;
|
||||
}
|
||||
|
||||
DeviceStateResponse state = controller.getState();
|
||||
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
|
||||
updateState(state);
|
||||
@ -351,7 +357,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
} else {
|
||||
refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
|
||||
}
|
||||
if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds)) {
|
||||
if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds, false)) {
|
||||
// After job has been rescheduled, request operating data one last time on next poll.
|
||||
// This is needed to update battery values after a charging cycle has completed.
|
||||
operatingDataTimestamp = Instant.MIN;
|
||||
|
@ -5,6 +5,8 @@ addon.boschindego.description = This is the binding for Bosch Indego Connect law
|
||||
|
||||
# thing types
|
||||
|
||||
thing-type.boschindego.account.label = SingleKey ID
|
||||
thing-type.boschindego.account.description = SingleKey ID account
|
||||
thing-type.boschindego.indego.label = Bosch Indego
|
||||
thing-type.boschindego.indego.description = Indego which supports the connect feature.
|
||||
|
||||
@ -12,14 +14,12 @@ thing-type.boschindego.indego.description = Indego which supports the connect fe
|
||||
|
||||
thing-type.config.boschindego.indego.cuttingTimeRefresh.label = Cutting Time Refresh Interval
|
||||
thing-type.config.boschindego.indego.cuttingTimeRefresh.description = The number of minutes between refreshing last/next cutting time.
|
||||
thing-type.config.boschindego.indego.password.label = Password
|
||||
thing-type.config.boschindego.indego.password.description = Password for the Bosch Indego account.
|
||||
thing-type.config.boschindego.indego.refresh.label = Idle Refresh Interval
|
||||
thing-type.config.boschindego.indego.refresh.description = The number of seconds between refreshing device state when idle.
|
||||
thing-type.config.boschindego.indego.serialNumber.label = Serial Number
|
||||
thing-type.config.boschindego.indego.serialNumber.description = The serial number of the connected Indego mower.
|
||||
thing-type.config.boschindego.indego.stateActiveRefresh.label = Active Refresh Interval
|
||||
thing-type.config.boschindego.indego.stateActiveRefresh.description = The number of seconds between refreshing device state when active.
|
||||
thing-type.config.boschindego.indego.username.label = Username
|
||||
thing-type.config.boschindego.indego.username.description = Username for the Bosch Indego account.
|
||||
|
||||
# channel types
|
||||
|
||||
@ -53,10 +53,11 @@ channel-type.boschindego.textualstate.label = Textual State
|
||||
|
||||
# thing status descriptions
|
||||
|
||||
offline.comm-error.authentication-failure = The login credentials are wrong or another client is connected to your Indego account
|
||||
offline.conf-error.missing-bridge = No bridge configured
|
||||
offline.conf-error.oauth2-unauthorized = Unauthorized
|
||||
offline.comm-error.oauth2-authorization-failed = Failed to authorize
|
||||
offline.comm-error.authentication-failure = Failed to authenticate with Bosch SingleKey ID
|
||||
offline.comm-error.unreachable = Device is unreachable
|
||||
offline.conf-error.missing-password = Password missing
|
||||
offline.conf-error.missing-username = Username missing
|
||||
|
||||
# indego states
|
||||
|
||||
|
@ -4,9 +4,19 @@
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<bridge-type id="account">
|
||||
<label>SingleKey ID</label>
|
||||
<description>SingleKey ID account</description>
|
||||
</bridge-type>
|
||||
|
||||
<thing-type id="indego">
|
||||
<supported-bridge-type-refs>
|
||||
<bridge-type-ref id="account"/>
|
||||
</supported-bridge-type-refs>
|
||||
|
||||
<label>Bosch Indego</label>
|
||||
<description>Indego which supports the connect feature.</description>
|
||||
|
||||
<channels>
|
||||
<channel id="state" typeId="state"/>
|
||||
<channel id="textualstate" typeId="textualstate"/>
|
||||
@ -23,15 +33,13 @@
|
||||
<channel id="gardenSize" typeId="gardenSize"/>
|
||||
<channel id="gardenMap" typeId="gardenMap"/>
|
||||
</channels>
|
||||
|
||||
<representation-property>serialNumber</representation-property>
|
||||
|
||||
<config-description>
|
||||
<parameter name="username" type="text" required="true">
|
||||
<label>Username</label>
|
||||
<description>Username for the Bosch Indego account.</description>
|
||||
</parameter>
|
||||
<parameter name="password" type="text" required="true">
|
||||
<context>password</context>
|
||||
<label>Password</label>
|
||||
<description>Password for the Bosch Indego account.</description>
|
||||
<parameter name="serialNumber" type="text" required="true">
|
||||
<label>Serial Number</label>
|
||||
<description>The serial number of the connected Indego mower.</description>
|
||||
</parameter>
|
||||
<parameter name="refresh" type="integer" min="60">
|
||||
<label>Idle Refresh Interval</label>
|
||||
|
Loading…
Reference in New Issue
Block a user