[boschindego] Rewrite to avoid external dependencies (#12905)

* Rewrite to avoid external dependencies

Fixes #12720

* Improve session handling
* Avoid reauthorization for each command/poll
* Further improve session handling
* Refactor SSO cookie handling
* Optimize getting DeviceStatus for unknown status code

Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
Jacob Laursen 2022-06-14 22:51:26 +02:00 committed by GitHub
parent fd9fa722d3
commit 960be6bc83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1721 additions and 239 deletions

View File

@ -1,69 +1,83 @@
# Bosch Indego Binding
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.
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.
## Configuration of the Thing
## Thing Configuration
Currently the binding supports ***indego*** mowers as a thing type with this parameters:
Currently the binding supports ***indego*** mowers as a thing type with these configuration parameters:
| parameter | datatype | required |
|-----------|----------|--------------------------------|
| username | String | yes |
| password | String | yes |
| refresh | integer | no (default: 180, minimum: 60) |
The refresh interval is specified in seconds.
A possible entry in your thing file could be:
```java
boschindego:indego:lawnmower [username="mail@example.com", password="idontneedtocutthelawnagain", refresh=120]
```
| Parameter | Description |
|-----------|----------------------------------------------------------------------|
| username | Username for the Bosch Indego account |
| password | Password for the Bosch Indego account |
| refresh | Specifies the refresh interval in seconds (default 180, minimum: 60) |
## Channels
| item-type | description | |
| Channel | Item Type | Description |
|--------------|-------------|-------------------------------------------------------------------------------------------------------------------------------------|
| state | Number | You can send commands to this channel to control the mower and read the simplified state from it (1=mow, 2=return to dock, 3=pause) |
| errorcode | Number | Errorcode of the mower (0=no error, readonly) |
| statecode | Number | Detailed state of the mower. I included English and German map-files to read the state easier (readonly) |
| errorcode | Number | Error code of the mower (0=no error, readonly) |
| statecode | Number | Detailed state of the mower (readonly) |
| textualstate | String | State as a text. (readonly) |
| ready | Number | Shows if the mower is ready to mow (1=ready, 0=not ready, readonly) |
| mowed | Dimmer | Cut grass in percent (readonly) |
For example you can use this sitemap entry to control the mower manually:
### State Codes
```perl
Switch item=indegostate mappings=[ 1="Mow", 2="Return",3="Pause" ]
| Code | Description |
|-------|---------------------------------------------|
| 0 | Reading status |
| 257 | Charging |
| 258 | Docked |
| 259 | Docked - Software update |
| 260 | Docked |
| 261 | Docked |
| 262 | Docked - Loading map |
| 263 | Docked - Saving map |
| 513 | Mowing |
| 514 | Relocalising |
| 515 | Loading map |
| 516 | Learning lawn |
| 517 | Paused |
| 518 | Border cut |
| 519 | Idle in lawn |
| 769 | Returning to dock |
| 770 | Returning to dock |
| 771 | Returning to dock - Battery low |
| 772 | Returning to dock - Calendar timeslot ended |
| 773 | Returning to dock - Battery temp range |
| 774 | Returning to dock |
| 775 | Returning to dock - Lawn complete |
| 776 | Returning to dock - Relocalising |
| 1025 | Diagnostic mode |
| 1026 | End of life |
| 1281 | Software update |
| 64513 | Docked |
## Full Example
### `indego.things` File
```
boschindego:indego:lawnmower [username="mail@example.com", password="idontneedtocutthelawnagain", refresh=120]
```
## Meaning of the numeric statecodes
### `indego.items` File
You can use this as .map file
```text
0=Reading status
257=Charging
258=Docked
259=Docked - Software update
260=Docked
261=Docked
262=Docked - Loading map
263=Docked - Saving map
513=Mowing
514=Relocalising
515=Loading map
516=Learning lawn
517=Paused
518=Border cut
519=Idle in lawn
769=Returning to Dock
770=Returning to Dock
771=Returning to Dock - Battery low
772=Returning to dock - Calendar timeslot ended
773=Returning to dock - Battery temp range
774=Returning to dock
775=Returning to dock - Lawn complete
776=Returning to dock - Relocalising
```
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" }
```
### `indego.sitemap` File
```
Switch item=Indego_State mappings=[1="Mow", 2="Return",3="Pause"]
```

View File

@ -14,35 +14,4 @@
<name>openHAB Add-ons :: Bundles :: Bosch Indego Binding</name>
<properties>
<dep.noembedding>httpclient-osgi,httpcore-osgi,commons-codec</dep.noembedding>
</properties>
<dependencies>
<dependency>
<groupId>de.zazaz.iot.bosch.indego</groupId>
<artifactId>bosch-indego-controller-lib</artifactId>
<version>0.8</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient-osgi</artifactId>
<version>4.5.5</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpcore-osgi</artifactId>
<version>4.4.9</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.10</version>
<scope>compile</scope>
</dependency>
</dependencies>
</project>

View File

@ -4,10 +4,6 @@
<feature name="openhab-binding-boschindego" description="Bosch Indego Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<feature dependency="true">openhab.tp-jackson</feature>
<bundle dependency="true">mvn:org.apache.httpcomponents/httpcore-osgi/4.4.9</bundle>
<bundle dependency="true">mvn:org.apache.httpcomponents/httpclient-osgi/4.5.5</bundle>
<bundle dependency="true">mvn:commons-codec/commons-codec/1.10</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.boschindego/${project.version}</bundle>
</feature>
</features>

View File

@ -12,6 +12,8 @@
*/
package org.openhab.binding.boschindego.internal;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
@ -36,4 +38,6 @@ public class BoschIndegoBindingConstants {
public static final String ERRORCODE = "errorcode";
public static final String STATECODE = "statecode";
public static final String READY = "ready";
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_INDEGO);
}

View File

@ -14,16 +14,20 @@ package org.openhab.binding.boschindego.internal;
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.THING_TYPE_INDEGO;
import java.util.Collections;
import java.util.Set;
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.BoschIndegoHandler;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
/**
* The {@link BoschIndegoHandlerFactory} is responsible for creating things and thing
@ -31,22 +35,30 @@ import org.osgi.service.component.annotations.Component;
*
* @author Jonas Fleck - Initial contribution
*/
@NonNullByDefault
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.boschindego")
public class BoschIndegoHandlerFactory extends BaseThingHandlerFactory {
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_INDEGO);
private final HttpClient httpClient;
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
@Activate
public BoschIndegoHandlerFactory(@Reference HttpClientFactory httpClientFactory,
ComponentContext componentContext) {
super.activate(componentContext);
this.httpClient = httpClientFactory.getCommonHttpClient();
}
@Override
protected ThingHandler createHandler(Thing thing) {
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return BoschIndegoBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (thingTypeUID.equals(THING_TYPE_INDEGO)) {
return new BoschIndegoHandler(thing);
if (THING_TYPE_INDEGO.equals(thingTypeUID)) {
return new BoschIndegoHandler(thing, httpClient);
}
return null;

View File

@ -0,0 +1,112 @@
/**
* 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 static java.util.Map.entry;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
/**
* {@link DeviceStatus} describes status codes from the device with corresponding
* ready state and associated command.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class DeviceStatus {
private static final Map<Integer, DeviceStatus> STATUS_MAP = Map.ofEntries(
entry(0, new DeviceStatus("Reading status", false, DeviceCommand.RETURN)),
entry(257, new DeviceStatus("Charging", false, DeviceCommand.RETURN)),
entry(258, new DeviceStatus("Docked", true, DeviceCommand.RETURN)),
entry(259, new DeviceStatus("Docked - Software update", false, DeviceCommand.RETURN)),
entry(260, new DeviceStatus("Docked", true, DeviceCommand.RETURN)),
entry(261, new DeviceStatus("Docked", true, DeviceCommand.RETURN)),
entry(262, new DeviceStatus("Docked - Loading map", false, DeviceCommand.MOW)),
entry(263, new DeviceStatus("Docked - Saving map", false, DeviceCommand.RETURN)),
entry(513, new DeviceStatus("Mowing", false, DeviceCommand.MOW)),
entry(514, new DeviceStatus("Relocalising", false, DeviceCommand.MOW)),
entry(515, new DeviceStatus("Loading map", false, DeviceCommand.MOW)),
entry(516, new DeviceStatus("Learning lawn", false, DeviceCommand.MOW)),
entry(517, new DeviceStatus("Paused", true, DeviceCommand.PAUSE)),
entry(518, new DeviceStatus("Border cut", false, DeviceCommand.MOW)),
entry(519, new DeviceStatus("Idle in lawn", true, DeviceCommand.MOW)),
entry(769, new DeviceStatus("Returning to dock", false, DeviceCommand.RETURN)),
entry(770, new DeviceStatus("Returning to dock", false, DeviceCommand.RETURN)),
entry(771, new DeviceStatus("Returning to dock - Battery low", false, DeviceCommand.RETURN)),
entry(772, new DeviceStatus("Returning to dock - Calendar timeslot ended", false, DeviceCommand.RETURN)),
entry(773, new DeviceStatus("Returning to dock - Battery temp range", false, DeviceCommand.RETURN)),
entry(774, new DeviceStatus("Returning to dock", false, DeviceCommand.RETURN)),
entry(775, new DeviceStatus("Returning to dock - Lawn complete", false, DeviceCommand.RETURN)),
entry(776, new DeviceStatus("Returning to dock - Relocalising", false, DeviceCommand.RETURN)),
entry(1025, new DeviceStatus("Diagnostic mode", false, null)),
entry(1026, new DeviceStatus("End of life", false, null)),
entry(1281, new DeviceStatus("Software update", false, null)),
entry(64513, new DeviceStatus("Docked", true, DeviceCommand.RETURN)));
private String message;
private boolean isReadyToMow;
private @Nullable DeviceCommand associatedCommand;
private DeviceStatus(String message, boolean isReadyToMow, @Nullable DeviceCommand associatedCommand) {
this.message = message;
this.isReadyToMow = isReadyToMow;
this.associatedCommand = associatedCommand;
}
/**
* Returns a {@link DeviceStatus} instance describing the status code.
*
* @param code the status code
* @return the {@link DeviceStatus} providing additional context for the code
*/
public static DeviceStatus fromCode(int code) {
DeviceStatus status = STATUS_MAP.get(code);
if (status != null) {
return status;
}
DeviceCommand command = null;
switch (code & 0xff00) {
case 0x100:
command = DeviceCommand.RETURN;
break;
case 0x200:
command = DeviceCommand.MOW;
break;
case 0x300:
command = DeviceCommand.RETURN;
break;
}
return new DeviceStatus(String.format("Unknown status code %d", code), false, command);
}
public String getMessage() {
return message;
}
public boolean isReadyToMow() {
return isReadyToMow;
}
public @Nullable DeviceCommand getAssociatedCommand() {
return associatedCommand;
}
}

View File

@ -0,0 +1,514 @@
/**
* 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.net.URI;
import java.time.Instant;
import java.util.Base64;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.HttpResponseException;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.client.api.Response;
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.LocationWeatherResponse;
import org.openhab.binding.boschindego.internal.dto.response.PredictiveCuttingTimeResponse;
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.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.
*
* @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";
private static final String CONTENT_TYPE_HEADER = "application/json";
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();
/**
* Initialize the controller instance.
*
* @param username the username for authenticating
* @param password the password
*/
public IndegoController(HttpClient httpClient, String username, String password) {
this.httpClient = httpClient;
basicAuthenticationHeader = "Basic "
+ Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
}
/**
* Authenticate with server and store session context and serial number.
*
* @throws IndegoAuthenticationException if request was rejected as unauthorized
* @throws IndegoException if any communication or parsing error occurred
*/
private void authenticate() throws IndegoAuthenticationException, IndegoException {
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);
int 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");
}
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");
}
session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
getContextExpirationTimeFromCookie());
logger.debug("Initialized session {}", session);
} catch (JsonParseException e) {
throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IndegoException(e);
} catch (TimeoutException | ExecutionException e) {
throw new IndegoException(e);
}
}
/**
* Get context expiration time as a calculated {@link Instant} relative to now.
* The information is obtained from max age in the Bosch Indego SSO cookie.
* Please note that this cookie is only sent initially when authenticating, so
* the value will not be subject to any updates.
*
* @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
*/
private Instant getContextExpirationTimeFromCookie() {
return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
.findFirst().map(c -> {
return Instant.now().plusSeconds(c.getMaxAge());
}).orElseGet(() -> {
return Instant.MIN;
});
}
/**
* 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 IndegoException if any communication or parsing error occurred
*/
private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
throws IndegoAuthenticationException, IndegoException {
if (!session.isValid()) {
authenticate();
}
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);
}
}
/**
* Sends a GET request to the server and returns the deserialized JSON response.
*
* @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 IndegoException if any communication or parsing error occurred
*/
private <T> T getRequest(String path, Class<? extends T> dtoClass)
throws IndegoAuthenticationException, IndegoException {
try {
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
session.getContextId());
if (logger.isTraceEnabled()) {
logger.trace("GET request for {}", BASE_URL + path);
}
ContentResponse response = sendRequest(request);
int status = response.getStatus();
if (status == HttpStatus.UNAUTHORIZED_401) {
// This will currently not happen because "WWW-Authenticate" header is missing; see below.
throw new IndegoAuthenticationException("Context 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");
}
logger.trace("JSON response: '{}'", jsonResponse);
@Nullable
T result = gson.fromJson(jsonResponse, dtoClass);
if (result == null) {
throw new IndegoInvalidResponseException("Parsed response is null");
}
return result;
} catch (JsonParseException e) {
throw new IndegoInvalidResponseException("Error parsing response", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IndegoException(e);
} catch (TimeoutException e) {
throw new IndegoException(e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause != null && cause instanceof HttpResponseException) {
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.
*/
throw new IndegoAuthenticationException("Context rejected", e);
}
}
throw new IndegoException(e);
}
}
/**
* Wraps {@link #putRequest(String, Object)} into an authenticated session.
*
* @param path the relative path to which the request should be sent
* @param requestDto the DTO which should be sent to the server as JSON
* @throws IndegoAuthenticationException if request was rejected as unauthorized
* @throws IndegoException if any communication or parsing error occurred
*/
private void putRequestWithAuthentication(String path, Object requestDto)
throws IndegoAuthenticationException, IndegoException {
if (!session.isValid()) {
authenticate();
}
try {
logger.debug("Session {} valid, skipping authentication", session);
putRequest(path, requestDto);
} catch (IndegoAuthenticationException e) {
if (logger.isTraceEnabled()) {
logger.trace("Context rejected", e);
} else {
logger.debug("Context rejected: {}", e.getMessage());
}
session.invalidate();
authenticate();
putRequest(path, requestDto);
}
}
/**
* Sends a PUT request to the server.
*
* @param path the relative path to which the request should be sent
* @param requestDto the DTO which should be sent to the server as JSON
* @throws IndegoAuthenticationException if request was rejected as unauthorized
* @throws IndegoException if any communication or parsing error occurred
*/
private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
try {
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
.header(CONTEXT_HEADER_NAME, session.getContextId())
.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
String payload = gson.toJson(requestDto);
request.content(new StringContentProvider(payload));
if (logger.isTraceEnabled()) {
logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
}
ContentResponse response = sendRequest(request);
int status = response.getStatus();
if (status == HttpStatus.UNAUTHORIZED_401) {
// This will currently not happen because "WWW-Authenticate" header is missing; see below.
throw new IndegoAuthenticationException("Context rejected");
}
if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
}
if (!HttpStatus.isSuccess(status)) {
throw new IndegoException("The request failed with error: " + status);
}
} catch (JsonParseException e) {
throw new IndegoInvalidResponseException("Error serializing request", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IndegoException(e);
} catch (TimeoutException e) {
throw new IndegoException(e);
} catch (ExecutionException e) {
Throwable cause = e.getCause();
if (cause != null && cause instanceof HttpResponseException) {
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.
*/
throw new IndegoAuthenticationException("Context rejected", e);
}
}
throw new IndegoException(e);
}
}
/**
* Send request. This method exists for the purpose of avoiding multiple calls to
* the server at the same time.
*
* @param request the {@link Request} to send
* @return a {@link ContentResponse} for this request
* @throws InterruptedException if send thread is interrupted
* @throws TimeoutException if send times out
* @throws ExecutionException if execution fails
*/
private 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 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 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 {
final PredictiveStatus status = getRequestWithAuthentication(
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", PredictiveStatus.class);
return status.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 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 Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
final PredictiveCuttingTimeResponse nextCutting = getRequestWithAuthentication(
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
PredictiveCuttingTimeResponse.class);
return nextCutting.getNextCutting();
}
/**
* 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 {
final DeviceCalendarResponse calendar = getRequestWithAuthentication(
SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", DeviceCalendarResponse.class);
return calendar;
}
/**
* 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);
}
}

View File

@ -0,0 +1,104 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.boschindego.internal;
import java.time.Duration;
import java.time.Instant;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* Session for storing Bosch Indego context information.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class IndegoSession {
private static final Duration DEFAULT_EXPIRATION_PERIOD = Duration.ofSeconds(10);
private String contextId;
private String serialNumber;
private Instant expirationTime;
public IndegoSession() {
this("", "", Instant.MIN);
}
public IndegoSession(String contextId, String serialNumber, Instant expirationTime) {
this.contextId = contextId;
this.serialNumber = serialNumber;
this.expirationTime = expirationTime.equals(Instant.MIN) ? Instant.now().plus(DEFAULT_EXPIRATION_PERIOD)
: expirationTime;
}
/**
* Get context id for HTTP requests (headers "x-im-context-id: <contextId>" and
* "Cookie: BOSCH_INDEGO_SSO=<contextId>").
*
* @return current context id
*/
public String getContextId() {
return contextId;
}
/**
* Get serial number of device.
*
* @return serial number
*/
public String getSerialNumber() {
return serialNumber;
}
/**
* Get expiration time of session as {@link Instant}.
*
* @return expiration time
*/
public Instant getExpirationTime() {
return expirationTime;
}
/**
* Check if session is initialized, i.e. has serial number.
*
* @see #isValid()
* @return true if session is initialized
*/
public boolean isInitialized() {
return !serialNumber.isEmpty();
}
/**
* Check if session is valid, i.e. has not yet expired.
*
* @return true if session is still valid
*/
public boolean isValid() {
return !contextId.isEmpty() && expirationTime.isAfter(Instant.now());
}
/**
* Invalidate session.
*/
public void invalidate() {
contextId = "";
expirationTime = Instant.MIN;
}
@Override
public String toString() {
return String.format("%s (serialNumber %s, expirationTime %s)", contextId, serialNumber, expirationTime);
}
}

View File

@ -10,20 +10,19 @@
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.boschindego.internal;
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.
*
* @author Jonas Fleck - Initial contribution
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class IndegoStateConstants {
public static final int STATE_DOCKED_1 = 258;
public static final int STATE_DOCKED_2 = 260;
public static final int STATE_DOCKED_3 = 261;
public static final int STATE_PAUSED = 517;
public static final int STATE_IDLE_IN_LAWN = 519;
public class BoschIndegoConfiguration {
public @Nullable String username;
public @Nullable String password;
public long refresh = 180;
}

View File

@ -0,0 +1,39 @@
/**
* 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.dto;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
/**
* Commands supported by the device.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public enum DeviceCommand {
MOW(SetStateRequest.STATE_MOW),
PAUSE(SetStateRequest.STATE_PAUSE),
RETURN(SetStateRequest.STATE_RETURN);
private String actionCode;
DeviceCommand(String actionCode) {
this.actionCode = actionCode;
}
public String getActionCode() {
return actionCode;
}
}

View File

@ -0,0 +1,25 @@
/**
* 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.dto;
import com.google.gson.annotations.SerializedName;
/**
* Request/response for user adjustment.
*
* @author Jacob Laursen - Initial contribution
*/
public class PredictiveAdjustment {
@SerializedName("user_adjustment")
public int adjustment;
}

View File

@ -0,0 +1,22 @@
/**
* 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.dto;
/**
* Request/response for predictive status.
*
* @author Jacob Laursen - Initial contribution
*/
public class PredictiveStatus {
public boolean enabled;
}

View File

@ -0,0 +1,44 @@
/**
* 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.dto.request;
import com.google.gson.annotations.SerializedName;
/**
* Request for authenticating with server
*
* @author Jacob Laursen - Initial contribution
*/
public class AuthenticationRequest {
@SerializedName("accept_tc_id")
public String acceptTcId;
public String device;
@SerializedName("os_type")
public String osType;
@SerializedName("os_version")
public String osVersion;
@SerializedName("dvc_manuf")
public String deviceManufacturer;
@SerializedName("dvc_type")
public String deviceType;
public AuthenticationRequest() {
acceptTcId = "202012";
}
}

View File

@ -0,0 +1,29 @@
/**
* 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.dto.request;
/**
* Request for setting a new device state
*
* @author Jacob Laursen - Initial contribution
*/
public class SetStateRequest {
public static final String STATE_MOW = "mow";
public static final String STATE_PAUSE = "pause";
public static final String STATE_RETURN = "returnToDock";
public String state;
}

View File

@ -0,0 +1,30 @@
/**
* 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.dto.response;
import com.google.gson.annotations.SerializedName;
/**
* Response from authenticating with server.
*
* @author Jacob Laursen - Initial contribution
*/
public class AuthenticationResponse {
public String contextId;
public String userId;
@SerializedName("alm_sn")
public String serialNumber;
}

View File

@ -0,0 +1,31 @@
/**
* 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.dto.response;
import org.openhab.binding.boschindego.internal.dto.response.calendar.DeviceCalendarEntry;
import com.google.gson.annotations.SerializedName;
/**
* Response for device calendar.
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceCalendarResponse {
@SerializedName("sel_cal")
public int selectedEntryNumber;
@SerializedName("cals")
public DeviceCalendarEntry[] entries;
}

View File

@ -0,0 +1,63 @@
/**
* 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.dto.response;
import org.openhab.binding.boschindego.internal.dto.response.runtime.DeviceStateRuntimes;
import com.google.gson.annotations.SerializedName;
/**
* Response after querying the device status.
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceStateResponse {
public int state;
public int error;
public boolean enabled;
@SerializedName("map_update_available")
public boolean mapUpdateAvailable;
public int mowed;
@SerializedName("mowmode")
public long mowMode;
public int xPos;
public int yPos;
public DeviceStateRuntimes runtime;
@SerializedName("mowed_ts")
public long mowedTimestamp;
@SerializedName("mapsvgcache_ts")
public long mapSvgCacheTimestamp;
@SerializedName("svg_xPos")
public int svgXPos;
@SerializedName("svg_yPos")
public int svgYPos;
@SerializedName("config_change")
public boolean configChange;
@SerializedName("mow_trig")
public boolean mowTrigger;
}

View File

@ -0,0 +1,28 @@
/**
* 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.dto.response;
import org.openhab.binding.boschindego.internal.dto.response.weather.Weather;
import com.google.gson.annotations.SerializedName;
/**
* Response for weather forecast.
*
* @author Jacob Laursen - Initial contribution
*/
public class LocationWeatherResponse {
@SerializedName("LocationWeather")
public Weather weather;
}

View File

@ -0,0 +1,38 @@
/**
* 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.dto.response;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import com.google.gson.annotations.SerializedName;
/**
* Response for next cutting time.
*
* @author Jacob Laursen - Initial contribution
*/
public class PredictiveCuttingTimeResponse {
@SerializedName("mow_next")
public String nextCutting;
public Instant getNextCutting() {
try {
return ZonedDateTime.parse(nextCutting).toInstant();
} catch (final DateTimeParseException e) {
// Ignored
}
return null;
}
}

View File

@ -0,0 +1,29 @@
/**
* 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.dto.response.calendar;
import com.google.gson.annotations.SerializedName;
/**
* Device calendar day entry.
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceCalendarDayEntry {
@SerializedName("day")
public int number;
@SerializedName("slots")
public DeviceCalendarDaySlot[] slots;
}

View File

@ -0,0 +1,38 @@
/**
* 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.dto.response.calendar;
import com.google.gson.annotations.SerializedName;
/**
* Device calendar day slot.
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceCalendarDaySlot {
@SerializedName("En")
public boolean enabled;
@SerializedName("StHr")
public int startHour;
@SerializedName("StMin")
public int startMinute;
@SerializedName("EnHr")
public int endHour;
@SerializedName("EnMin")
public int endMinute;
}

View File

@ -0,0 +1,28 @@
/**
* 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.dto.response.calendar;
import com.google.gson.annotations.SerializedName;
/**
* Device calendar entry.
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceCalendarEntry {
@SerializedName("cal")
public int number;
public DeviceCalendarDayEntry[] days;
}

View File

@ -0,0 +1,25 @@
/**
* 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.dto.response.runtime;
/**
* Detailed runtime information for {@link DeviceStateRuntimes}
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceStateRuntime {
public long operate;
public long charge;
}

View File

@ -0,0 +1,25 @@
/**
* 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.dto.response.runtime;
/**
* Total/session runtime information for {@link DeviceStateResponse}
*
* @author Jacob Laursen - Initial contribution
*/
public class DeviceStateRuntimes {
public DeviceStateRuntime total;
public DeviceStateRuntime session;
}

View File

@ -0,0 +1,23 @@
/**
* 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.dto.response.weather;
/**
* Forecast.
*
* @author Jacob Laursen - Initial contribution
*/
public class Forecast {
public Interval[] intervals;
}

View File

@ -0,0 +1,51 @@
/**
* 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.dto.response.weather;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;
import com.google.gson.annotations.SerializedName;
/**
* Interval.
*
* @author Jacob Laursen - Initial contribution
*/
public class Interval {
@SerializedName("dateTime")
public String date;
public int intervalLength;
@SerializedName("prrr")
public int rain;
@SerializedName("tt")
public float temperature;
public void setDate(final Instant date) {
this.date = date.toString();
}
public Instant getDate() {
try {
return ZonedDateTime.parse(date).toInstant();
} catch (final DateTimeParseException e) {
// Ignored
}
return null;
}
}

View File

@ -0,0 +1,31 @@
/**
* 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.dto.response.weather;
import com.google.gson.annotations.SerializedName;
/**
* Location.
*
* @author Jacob Laursen - Initial contribution
*/
public class Location {
@SerializedName("name")
public String town;
public String country;
@SerializedName("tzn")
public String timeZone;
}

View File

@ -0,0 +1,24 @@
/**
* 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.dto.response.weather;
/**
* Weather.
*
* @author Jacob Laursen - Initial contribution
*/
public class Weather {
public Location location;
public Forecast forecast;
}

View File

@ -0,0 +1,35 @@
/**
* 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.exceptions;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* {@link IndegoAuthenticationException} is thrown on authentication failure, for example
* when username or password is wrong.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class IndegoAuthenticationException extends IndegoException {
private static final long serialVersionUID = -9047922366108411751L;
public IndegoAuthenticationException(String message) {
super(message);
}
public IndegoAuthenticationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,40 @@
/**
* 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.exceptions;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* {@link IndegoException} is a generic Indego exception thrown in case
* of communication failure or unexpected response. It is intended to
* be derived by specialized exceptions.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class IndegoException extends Exception {
private static final long serialVersionUID = 6673869982385647268L;
public IndegoException(String message) {
super(message);
}
public IndegoException(Throwable cause) {
super(cause);
}
public IndegoException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,30 @@
/**
* 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.exceptions;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* {@link IndegoInvalidCommandException} is thrown when a command is rejected by the device.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class IndegoInvalidCommandException extends IndegoException {
private static final long serialVersionUID = -2946398731437793113L;
public IndegoInvalidCommandException(String message) {
super(message);
}
}

View File

@ -0,0 +1,35 @@
/**
* 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.exceptions;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* {@link IndegoInvalidResponseException} is thrown in case of invalid response from the
* Bosch Indego service.
*
* @author Jacob Laursen - Initial contribution
*/
@NonNullByDefault
public class IndegoInvalidResponseException extends IndegoException {
private static final long serialVersionUID = -4236849226899489934L;
public IndegoInvalidResponseException(String message) {
super(message);
}
public IndegoInvalidResponseException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -13,15 +13,20 @@
package org.openhab.binding.boschindego.internal.handler;
import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
import static org.openhab.binding.boschindego.internal.IndegoStateConstants.*;
import java.math.BigDecimal;
import java.util.LinkedList;
import java.util.Map;
import java.util.Queue;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.boschindego.internal.DeviceStatus;
import org.openhab.binding.boschindego.internal.IndegoController;
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;
import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StringType;
@ -36,46 +41,83 @@ import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import de.zazaz.iot.bosch.indego.DeviceCommand;
import de.zazaz.iot.bosch.indego.DeviceStateInformation;
import de.zazaz.iot.bosch.indego.DeviceStatus;
import de.zazaz.iot.bosch.indego.IndegoAuthenticationException;
import de.zazaz.iot.bosch.indego.IndegoController;
import de.zazaz.iot.bosch.indego.IndegoException;
/**
* The {@link BoschIndegoHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Jonas Fleck - Initial contribution
* @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
*/
@NonNullByDefault
public class BoschIndegoHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
private final Queue<DeviceCommand> commandQueue = new LinkedList<>();
private final HttpClient httpClient;
private ScheduledFuture<?> pollFuture;
private @NonNullByDefault({}) IndegoController controller;
private @Nullable ScheduledFuture<?> pollFuture;
private long refreshRate;
private boolean propertiesInitialized;
// If false the request is already scheduled.
private boolean shouldReschedule;
public BoschIndegoHandler(Thing thing) {
public BoschIndegoHandler(Thing thing, HttpClient httpClient) {
super(thing);
this.httpClient = httpClient;
}
@Override
public void initialize() {
logger.debug("Initializing Indego handler");
BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
String username = config.username;
String password = config.password;
if (username == null || username.isBlank()) {
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");
return;
}
controller = new IndegoController(httpClient, username, password);
refreshRate = config.refresh;
updateStatus(ThingStatus.UNKNOWN);
this.pollFuture = scheduler.scheduleWithFixedDelay(this::refreshState, 0, refreshRate, TimeUnit.SECONDS);
}
@Override
public void dispose() {
logger.debug("Disposing Indego handler");
ScheduledFuture<?> pollFuture = this.pollFuture;
if (pollFuture != null) {
pollFuture.cancel(true);
}
this.pollFuture = null;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
// Currently manual refreshing is not possible in the moment
if (command == RefreshType.REFRESH) {
scheduler.submit(() -> this.refreshState());
return;
} else if (channelUID.getId().equals(STATE) && command instanceof DecimalType) {
if (command instanceof DecimalType) {
}
try {
if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
sendCommand(((DecimalType) command).intValue());
}
} catch (IndegoAuthenticationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.comm-error.authentication-failure");
} catch (IndegoException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
private void sendCommand(int commandInt) {
private void sendCommand(int commandInt) throws IndegoException {
DeviceCommand command;
switch (commandInt) {
case 1:
@ -88,94 +130,62 @@ public class BoschIndegoHandler extends BaseThingHandler {
command = DeviceCommand.PAUSE;
break;
default:
logger.error("Invalid command");
logger.warn("Invalid command {}", commandInt);
return;
}
synchronized (commandQueue) {
// Add command to queue to avoid blocking
commandQueue.offer(command);
if (shouldReschedule) {
shouldReschedule = false;
reschedule();
}
DeviceStateResponse state = controller.getState();
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
if (!verifyCommand(command, deviceStatus, state.error)) {
return;
}
logger.debug("Sending command {}", command);
updateState(TEXTUAL_STATE, UnDefType.UNDEF);
controller.sendCommand(command);
state = controller.getState();
updateStatus(ThingStatus.ONLINE);
updateState(state);
}
private synchronized void poll() {
// Create controller instance
private void refreshState() {
try {
IndegoController controller = new IndegoController(getConfig().get("username").toString(),
getConfig().get("password").toString());
// Connect to server
controller.connect();
// Query the device state
DeviceStateInformation state = controller.getState();
DeviceStatus statusWithMessage = DeviceStatus.decodeStatusCode(state.getState());
int status = getStatusFromCommand(statusWithMessage.getAssociatedCommand());
int mowed = state.getMowed();
int error = state.getError();
int statecode = state.getState();
boolean ready = isReadyToMow(state.getState(), state.getError());
DeviceCommand commandToSend = null;
synchronized (commandQueue) {
// Discard older commands
while (!commandQueue.isEmpty()) {
commandToSend = commandQueue.poll();
}
// For newer commands a new request is needed
shouldReschedule = true;
if (!propertiesInitialized) {
getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
propertiesInitialized = true;
}
if (commandToSend != null && verifyCommand(commandToSend, statusWithMessage.getAssociatedCommand(),
state.getState(), error)) {
logger.debug("Sending command...");
updateState(TEXTUAL_STATE, UnDefType.UNDEF);
controller.sendCommand(commandToSend);
try {
for (int i = 0; i < 30 && !Thread.interrupted(); i++) {
DeviceStateInformation stateTmp = controller.getState();
if (state.getState() != stateTmp.getState()) {
state = stateTmp;
statusWithMessage = DeviceStatus.decodeStatusCode(state.getState());
status = getStatusFromCommand(statusWithMessage.getAssociatedCommand());
mowed = state.getMowed();
error = state.getError();
statecode = state.getState();
ready = isReadyToMow(state.getState(), state.getError());
break;
}
Thread.sleep(1000);
}
} catch (InterruptedException e) {
// Nothing to do here
}
}
controller.disconnect();
DeviceStateResponse state = controller.getState();
updateStatus(ThingStatus.ONLINE);
updateState(STATECODE, new DecimalType(statecode));
updateState(READY, new DecimalType(ready ? 1 : 0));
updateState(ERRORCODE, new DecimalType(error));
updateState(MOWED, new PercentType(mowed));
updateState(STATE, new DecimalType(status));
updateState(TEXTUAL_STATE, new StringType(statusWithMessage.getMessage()));
updateState(state);
} catch (IndegoAuthenticationException e) {
String message = "The login credentials are wrong or another client connected to your Indego account";
logger.warn(message, e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.comm-error.authentication-failure");
} catch (IndegoException e) {
logger.warn("An error occurred", e);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
}
}
private boolean isReadyToMow(int statusCode, int error) {
// I don´t know why bosch uses different state codes for the same state.
return (statusCode == STATE_DOCKED_1 || statusCode == STATE_DOCKED_2 || statusCode == STATE_DOCKED_3
|| statusCode == STATE_PAUSED || statusCode == STATE_IDLE_IN_LAWN) && error == 0;
private void updateState(DeviceStateResponse state) {
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
int status = getStatusFromCommand(deviceStatus.getAssociatedCommand());
int mowed = state.mowed;
int error = state.error;
int statecode = state.state;
boolean ready = isReadyToMow(deviceStatus, state.error);
updateState(STATECODE, new DecimalType(statecode));
updateState(READY, new DecimalType(ready ? 1 : 0));
updateState(ERRORCODE, new DecimalType(error));
updateState(MOWED, new PercentType(mowed));
updateState(STATE, new DecimalType(status));
updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage()));
}
private boolean verifyCommand(DeviceCommand command, DeviceCommand state, int statusCode, int errorCode) {
private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
return deviceStatus.isReadyToMow() && error == 0;
}
private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
// Mower reported an error
if (errorCode != 0) {
logger.error("The mower reported an error.");
@ -183,24 +193,27 @@ public class BoschIndegoHandler extends BaseThingHandler {
}
// Command is equal to current state
if (command == state) {
if (command == deviceStatus.getAssociatedCommand()) {
logger.debug("Command is equal to state");
return false;
}
// Cant pause while the mower is docked
if (command == DeviceCommand.PAUSE && state == DeviceCommand.RETURN) {
logger.debug("Can´t pause the mower while it´s docked or docking");
if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
logger.debug("Can't pause the mower while it's docked or docking");
return false;
}
// Command means "MOW" but mower is not ready
if (command == DeviceCommand.MOW && !isReadyToMow(statusCode, errorCode)) {
logger.debug("The mower is not ready to mow in the moment");
if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
logger.debug("The mower is not ready to mow at the moment");
return false;
}
return true;
}
private int getStatusFromCommand(DeviceCommand command) {
private int getStatusFromCommand(@Nullable DeviceCommand command) {
if (command == null) {
return 0;
}
int status;
switch (command) {
case MOW:
@ -217,36 +230,4 @@ public class BoschIndegoHandler extends BaseThingHandler {
}
return status;
}
@Override
public void dispose() {
super.dispose();
logger.debug("removing thing..");
if (pollFuture != null) {
pollFuture.cancel(true);
}
}
private void reschedule() {
logger.debug("rescheduling");
if (pollFuture != null) {
pollFuture.cancel(false);
}
int refreshRate = ((BigDecimal) getConfig().get("refresh")).intValue();
pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 0, refreshRate, TimeUnit.SECONDS);
}
@Override
public void handleConfigurationUpdate(Map<String, Object> configurationParameters) {
super.handleConfigurationUpdate(configurationParameters);
reschedule();
}
@Override
public void initialize() {
updateStatus(ThingStatus.OFFLINE);
reschedule();
}
}

View File

@ -54,5 +54,15 @@ channel-type.boschindego.statecode.state.option.772 = Returning to Dock - Calend
channel-type.boschindego.statecode.state.option.773 = Returning to Dock - Battery temp range
channel-type.boschindego.statecode.state.option.774 = Returning to Dock
channel-type.boschindego.statecode.state.option.775 = Returning to Dock - Lawn complete
channel-type.boschindego.statecode.state.option.775 = Returning to Dock - Relocalising
channel-type.boschindego.statecode.state.option.776 = Returning to Dock - Relocalising
channel-type.boschindego.statecode.state.option.1025 = Diagnostic mode
channel-type.boschindego.statecode.state.option.1026 = End of life
channel-type.boschindego.statecode.state.option.1281 = Software update
channel-type.boschindego.statecode.state.option.64513 = Docked
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-password = Password missing
offline.conf-error.missing-username = Username missing

View File

@ -48,7 +48,7 @@
<item-type>Number</item-type>
<label>Error Code</label>
<description>0 = no error</description>
<state readOnly="false"></state>
<state readOnly="true"></state>
</channel-type>
<channel-type id="statecode" advanced="true">
<item-type>Number</item-type>
@ -78,7 +78,11 @@
<option value="773">Returning to Dock - Battery temp range</option>
<option value="774">Returning to Dock</option>
<option value="775">Returning to Dock - Lawn complete</option>
<option value="775">Returning to Dock - Relocalising</option>
<option value="776">Returning to Dock - Relocalising</option>
<option value="1025">Diagnostic mode</option>
<option value="1026">End of life</option>
<option value="1281">Software update</option>
<option value="64513">Docked</option>
</options>
</state>
</channel-type>