mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-25 14:55:55 +01:00
[boschindego] Provide faster channel updates (#13192)
* Optimize API calls for reduced load * Add position tracking (on map) * Provide faster updates when active * Optimize state update after triggering commands * Clean up duration variables * Add initial test coverage for DeviceStatus Signed-off-by: Jacob Laursen <jacob-github@vindvejr.dk>
This commit is contained in:
parent
c7f8507cae
commit
2de6dd0310
@ -8,12 +8,13 @@ His [Java Library](https://github.com/zazaz-de/iot-device-bosch-indego-controlle
|
||||
|
||||
Currently the binding supports ***indego*** mowers as a thing type with these configuration parameters:
|
||||
|
||||
| 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 | 180 |
|
||||
| cuttingTimeRefresh | The number of minutes between refreshing last/next cutting time | 60 |
|
||||
| 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 |
|
||||
|
||||
## Channels
|
||||
|
||||
@ -27,12 +28,15 @@ Currently the binding supports ***indego*** mowers as a thing type with these
|
||||
| mowed | Dimmer | Cut grass in percent | |
|
||||
| lastCutting | DateTime | Last cutting time | |
|
||||
| nextCutting | DateTime | Next scheduled cutting time | |
|
||||
| batteryVoltage | Number:ElectricPotential | Battery voltage reported by the device | |
|
||||
| batteryLevel | Number | Battery level as a percentage (0-100%) | |
|
||||
| lowBattery | Switch | Low battery warning with possible values on (low battery) and off (battery ok) | |
|
||||
| batteryTemperature | Number:Temperature | Battery temperature reported by the device | |
|
||||
| batteryVoltage | Number:ElectricPotential | Battery voltage reported by the device [^1] | |
|
||||
| batteryLevel | Number | Battery level as a percentage (0-100%) [^1] | |
|
||||
| lowBattery | Switch | Low battery warning with possible values on (low battery) and off (battery ok) [^1] | |
|
||||
| batteryTemperature | Number:Temperature | Battery temperature reported by the device [^1] | |
|
||||
| gardenSize | Number:Area | Garden size mapped by the device | |
|
||||
| gardenMap | Image | Garden map mapped by the device | |
|
||||
| gardenMap | Image | Garden map created by the device [^2] | |
|
||||
|
||||
[^1]: This will be updated every six hours when the device is idle. It will wake up the device, which can include turning on its display. When the device is active or charging, this will be updated every two minutes.
|
||||
[^2]: This will be updated as often as specified by the `stateActiveRefresh` thing parameter.
|
||||
|
||||
### State Codes
|
||||
|
||||
|
@ -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;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
|
||||
/**
|
||||
* {@link DeviceStateAttribute} describes a characteristic for a device state.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public enum DeviceStateAttribute {
|
||||
READY_TO_MOW,
|
||||
DOCKED,
|
||||
CHARGING,
|
||||
ACTIVE,
|
||||
COMPLETED
|
||||
}
|
@ -14,6 +14,7 @@ package org.openhab.binding.boschindego.internal;
|
||||
|
||||
import static java.util.Map.entry;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Map;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
@ -22,59 +23,90 @@ import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
||||
|
||||
/**
|
||||
* {@link DeviceStatus} describes status codes from the device with corresponding
|
||||
* ready state and associated command.
|
||||
* characteristics and associated command.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DeviceStatus {
|
||||
|
||||
public static final int STATE_LEARNING_LAWN = 516;
|
||||
|
||||
private final static String STATE_PREFIX = "indego.state.";
|
||||
private final static String STATE_UNKNOWN = "unknown";
|
||||
private static final String STATE_PREFIX = "indego.state.";
|
||||
private static final String STATE_UNKNOWN = "unknown";
|
||||
|
||||
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(266, new DeviceStatus("leaving-dock", false, DeviceCommand.MOW)),
|
||||
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(STATE_LEARNING_LAWN, 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(523, new DeviceStatus("spotmow", false, 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(1537, new DeviceStatus("energy-save-mode", true, DeviceCommand.RETURN)),
|
||||
entry(64513, new DeviceStatus("docked", true, DeviceCommand.RETURN)));
|
||||
entry(0, new DeviceStatus("reading-status", EnumSet.noneOf(DeviceStateAttribute.class),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(257,
|
||||
new DeviceStatus("charging", EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.CHARGING),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(258, new DeviceStatus("docked",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.READY_TO_MOW), DeviceCommand.RETURN)),
|
||||
entry(259,
|
||||
new DeviceStatus("docked-software-update", EnumSet.of(DeviceStateAttribute.DOCKED),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(260, new DeviceStatus("docked",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.READY_TO_MOW), DeviceCommand.RETURN)),
|
||||
entry(261, new DeviceStatus("docked",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.READY_TO_MOW), DeviceCommand.RETURN)),
|
||||
entry(262,
|
||||
new DeviceStatus("docked-loading-map",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(263, new DeviceStatus("docked-saving-map",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.ACTIVE), DeviceCommand.RETURN)),
|
||||
entry(266,
|
||||
new DeviceStatus("leaving-dock",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(513, new DeviceStatus("mowing", EnumSet.of(DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(514, new DeviceStatus("relocalising", EnumSet.of(DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(515, new DeviceStatus("loading-map", EnumSet.noneOf(DeviceStateAttribute.class), DeviceCommand.MOW)),
|
||||
entry(516, new DeviceStatus("learning-lawn", EnumSet.of(DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(517, new DeviceStatus("paused", EnumSet.of(DeviceStateAttribute.READY_TO_MOW), DeviceCommand.PAUSE)),
|
||||
entry(518, new DeviceStatus("border-cut", EnumSet.of(DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(519,
|
||||
new DeviceStatus("idle-in-lawn", EnumSet.of(DeviceStateAttribute.READY_TO_MOW), DeviceCommand.MOW)),
|
||||
entry(523, new DeviceStatus("spotmow", EnumSet.of(DeviceStateAttribute.ACTIVE), DeviceCommand.MOW)),
|
||||
entry(769,
|
||||
new DeviceStatus("returning-to-dock", EnumSet.of(DeviceStateAttribute.ACTIVE),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(770,
|
||||
new DeviceStatus("returning-to-dock", EnumSet.of(DeviceStateAttribute.ACTIVE),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(771,
|
||||
new DeviceStatus("returning-to-dock-battery-low", EnumSet.of(DeviceStateAttribute.ACTIVE),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(772,
|
||||
new DeviceStatus("returning-to-dock-calendar-timeslot-ended",
|
||||
EnumSet.of(DeviceStateAttribute.ACTIVE), DeviceCommand.RETURN)),
|
||||
entry(773,
|
||||
new DeviceStatus("returning-to-dock-battery-temp-range", EnumSet.of(DeviceStateAttribute.ACTIVE),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(774,
|
||||
new DeviceStatus("returning-to-dock", EnumSet.of(DeviceStateAttribute.ACTIVE),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(775, new DeviceStatus("returning-to-dock-lawn-complete",
|
||||
EnumSet.of(DeviceStateAttribute.ACTIVE, DeviceStateAttribute.COMPLETED), DeviceCommand.RETURN)),
|
||||
entry(776,
|
||||
new DeviceStatus("returning-to-dock-relocalising", EnumSet.of(DeviceStateAttribute.ACTIVE),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(1025, new DeviceStatus("diagnostic-mode", EnumSet.noneOf(DeviceStateAttribute.class), null)),
|
||||
entry(1026, new DeviceStatus("end-of-life", EnumSet.noneOf(DeviceStateAttribute.class), null)),
|
||||
entry(1281, new DeviceStatus("software-update", EnumSet.noneOf(DeviceStateAttribute.class), null)),
|
||||
entry(1537,
|
||||
new DeviceStatus("energy-save-mode", EnumSet.of(DeviceStateAttribute.READY_TO_MOW),
|
||||
DeviceCommand.RETURN)),
|
||||
entry(64513, new DeviceStatus("docked",
|
||||
EnumSet.of(DeviceStateAttribute.DOCKED, DeviceStateAttribute.READY_TO_MOW), DeviceCommand.RETURN)));
|
||||
|
||||
private String textKey;
|
||||
|
||||
private boolean isReadyToMow;
|
||||
private EnumSet<DeviceStateAttribute> attributes;
|
||||
|
||||
private @Nullable DeviceCommand associatedCommand;
|
||||
|
||||
private DeviceStatus(String textKey, boolean isReadyToMow, @Nullable DeviceCommand associatedCommand) {
|
||||
private DeviceStatus(String textKey, EnumSet<DeviceStateAttribute> attributes,
|
||||
@Nullable DeviceCommand associatedCommand) {
|
||||
this.textKey = textKey;
|
||||
this.isReadyToMow = isReadyToMow;
|
||||
this.attributes = attributes;
|
||||
this.associatedCommand = associatedCommand;
|
||||
}
|
||||
|
||||
@ -91,19 +123,22 @@ public class DeviceStatus {
|
||||
}
|
||||
|
||||
DeviceCommand command = null;
|
||||
EnumSet<DeviceStateAttribute> attributes = EnumSet.noneOf(DeviceStateAttribute.class);
|
||||
switch (code & 0xff00) {
|
||||
case 0x100:
|
||||
command = DeviceCommand.RETURN;
|
||||
break;
|
||||
case 0x200:
|
||||
command = DeviceCommand.MOW;
|
||||
attributes.add(DeviceStateAttribute.ACTIVE);
|
||||
break;
|
||||
case 0x300:
|
||||
command = DeviceCommand.RETURN;
|
||||
attributes.add(DeviceStateAttribute.ACTIVE);
|
||||
break;
|
||||
}
|
||||
|
||||
return new DeviceStatus(String.valueOf(code), false, command);
|
||||
return new DeviceStatus(String.valueOf(code), attributes, command);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -121,7 +156,23 @@ public class DeviceStatus {
|
||||
}
|
||||
|
||||
public boolean isReadyToMow() {
|
||||
return isReadyToMow;
|
||||
return attributes.contains(DeviceStateAttribute.READY_TO_MOW);
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return attributes.contains(DeviceStateAttribute.ACTIVE);
|
||||
}
|
||||
|
||||
public boolean isCharging() {
|
||||
return attributes.contains(DeviceStateAttribute.CHARGING);
|
||||
}
|
||||
|
||||
public boolean isDocked() {
|
||||
return attributes.contains(DeviceStateAttribute.DOCKED);
|
||||
}
|
||||
|
||||
public boolean isCompleted() {
|
||||
return attributes.contains(DeviceStateAttribute.COMPLETED);
|
||||
}
|
||||
|
||||
public @Nullable DeviceCommand getAssociatedCommand() {
|
||||
|
@ -13,6 +13,7 @@
|
||||
package org.openhab.binding.boschindego.internal;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
@ -371,7 +372,7 @@ public class IndegoController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps {@link #putRequest(String, Object)} into an authenticated session.
|
||||
* Wraps {@link #putPostRequest(HttpMethod, 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
|
||||
@ -385,7 +386,7 @@ public class IndegoController {
|
||||
}
|
||||
try {
|
||||
logger.debug("Session {} valid, skipping authentication", session);
|
||||
putRequest(path, requestDto);
|
||||
putPostRequest(HttpMethod.PUT, path, requestDto);
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("Context rejected", e);
|
||||
@ -394,27 +395,59 @@ public class IndegoController {
|
||||
}
|
||||
session.invalidate();
|
||||
authenticate();
|
||||
putRequest(path, requestDto);
|
||||
putPostRequest(HttpMethod.PUT, path, requestDto);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PUT request to the server.
|
||||
* Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
|
||||
*
|
||||
* @param path the relative path to which the request should be sent
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a PUT/POST request to the server.
|
||||
*
|
||||
* @param method the type of request ({@link HttpMethod.PUT} or {@link HttpMethod.POST})
|
||||
* @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 {
|
||||
private void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
|
||||
throws IndegoAuthenticationException, IndegoException {
|
||||
try {
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
|
||||
Request request = httpClient.newRequest(BASE_URL + path).method(method)
|
||||
.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);
|
||||
if (requestDto != null) {
|
||||
String payload = gson.toJson(requestDto);
|
||||
request.content(new StringContentProvider(payload));
|
||||
if (logger.isTraceEnabled()) {
|
||||
logger.trace("{} request for {} with payload '{}'", method, BASE_URL + path, payload);
|
||||
}
|
||||
} else {
|
||||
logger.trace("{} request for {} with no payload", method, BASE_URL + path);
|
||||
}
|
||||
ContentResponse response = sendRequest(request);
|
||||
int status = response.getStatus();
|
||||
@ -521,6 +554,21 @@ public class IndegoController {
|
||||
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.
|
||||
@ -702,4 +750,17 @@ public class IndegoController {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -25,5 +25,6 @@ public class BoschIndegoConfiguration {
|
||||
public @Nullable String username;
|
||||
public @Nullable String password;
|
||||
public long refresh = 180;
|
||||
public long stateActiveRefresh = 30;
|
||||
public long cuttingTimeRefresh = 60;
|
||||
}
|
||||
|
@ -41,8 +41,21 @@ public class DeviceStateResponse {
|
||||
|
||||
public int yPos;
|
||||
|
||||
/**
|
||||
* This is returned only for non-longpoll requests.
|
||||
*/
|
||||
public DeviceStateRuntimes runtime;
|
||||
|
||||
/**
|
||||
* This is returned only for longpoll requests.
|
||||
*/
|
||||
public long charge;
|
||||
|
||||
/**
|
||||
* This is returned only for longpoll requests.
|
||||
*/
|
||||
public long operate;
|
||||
|
||||
@SerializedName("mowed_ts")
|
||||
public long mowedTimestamp;
|
||||
|
||||
|
@ -70,7 +70,12 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
private static final String MAP_POSITION_STROKE_COLOR = "#8c8b6d";
|
||||
private static final String MAP_POSITION_FILL_COLOR = "#fff701";
|
||||
private static final int MAP_POSITION_RADIUS = 10;
|
||||
private static final int MAP_REFRESH_INTERVAL_DAYS = 1;
|
||||
|
||||
private static final Duration MAP_REFRESH_INTERVAL = Duration.ofDays(1);
|
||||
private static final Duration OPERATING_DATA_INACTIVE_REFRESH_INTERVAL = Duration.ofHours(6);
|
||||
private static final Duration OPERATING_DATA_ACTIVE_REFRESH_INTERVAL = Duration.ofMinutes(2);
|
||||
private static final Duration MAP_REFRESH_SESSION_DURATION = Duration.ofMinutes(5);
|
||||
private static final Duration COMMAND_STATE_REFRESH_TIMEOUT = Duration.ofSeconds(10);
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
|
||||
private final HttpClient httpClient;
|
||||
@ -85,6 +90,11 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
private Optional<Integer> previousStateCode = Optional.empty();
|
||||
private @Nullable RawType cachedMap;
|
||||
private Instant cachedMapTimestamp = Instant.MIN;
|
||||
private Instant operatingDataTimestamp = Instant.MIN;
|
||||
private Instant mapRefreshStartedTimestamp = Instant.MIN;
|
||||
private int stateInactiveRefreshIntervalSeconds;
|
||||
private int stateActiveRefreshIntervalSeconds;
|
||||
private int currentRefreshIntervalSeconds;
|
||||
|
||||
public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
|
||||
TimeZoneProvider timeZoneProvider) {
|
||||
@ -98,6 +108,8 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
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;
|
||||
|
||||
@ -116,12 +128,29 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
previousStateCode = Optional.empty();
|
||||
this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateAndOperatingDataWithExceptionHandling,
|
||||
0, config.refresh, TimeUnit.SECONDS);
|
||||
rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds);
|
||||
this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
|
||||
config.cuttingTimeRefresh, TimeUnit.MINUTES);
|
||||
}
|
||||
|
||||
private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds) {
|
||||
ScheduledFuture<?> statePollFuture = this.statePollFuture;
|
||||
if (statePollFuture != null) {
|
||||
if (refreshIntervalSeconds == currentRefreshIntervalSeconds) {
|
||||
// No change.
|
||||
return false;
|
||||
}
|
||||
statePollFuture.cancel(false);
|
||||
}
|
||||
logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
|
||||
delaySeconds);
|
||||
this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateWithExceptionHandling, delaySeconds,
|
||||
refreshIntervalSeconds, TimeUnit.SECONDS);
|
||||
currentRefreshIntervalSeconds = refreshIntervalSeconds;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
logger.debug("Disposing Indego handler");
|
||||
@ -187,8 +216,10 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
refreshState();
|
||||
break;
|
||||
case LAST_CUTTING:
|
||||
refreshLastCuttingTime();
|
||||
break;
|
||||
case NEXT_CUTTING:
|
||||
refreshCuttingTimes();
|
||||
refreshNextCuttingTime();
|
||||
break;
|
||||
case BATTERY_LEVEL:
|
||||
case LOW_BATTERY:
|
||||
@ -223,15 +254,23 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
return;
|
||||
}
|
||||
logger.debug("Sending command {}", command);
|
||||
updateState(TEXTUAL_STATE, UnDefType.UNDEF);
|
||||
controller.sendCommand(command);
|
||||
refreshState();
|
||||
|
||||
// State is not updated immediately, so await new state for some seconds.
|
||||
// For command MOW, state will shortly be updated to 262 (docked, loading map).
|
||||
// This is considered "active", so after this state change, polling frequency will
|
||||
// be increased for faster updates.
|
||||
DeviceStateResponse stateResponse = controller.getState(COMMAND_STATE_REFRESH_TIMEOUT);
|
||||
if (stateResponse.state != 0) {
|
||||
updateState(stateResponse);
|
||||
deviceStatus = DeviceStatus.fromCode(stateResponse.state);
|
||||
rescheduleStatePollAccordingToState(deviceStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshStateAndOperatingDataWithExceptionHandling() {
|
||||
private void refreshStateWithExceptionHandling() {
|
||||
try {
|
||||
refreshState();
|
||||
refreshOperatingData();
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.comm-error.authentication-failure");
|
||||
@ -250,34 +289,80 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
}
|
||||
|
||||
DeviceStateResponse state = controller.getState();
|
||||
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
|
||||
updateState(state);
|
||||
|
||||
// Update map and start tracking positions if mower is active.
|
||||
if (state.mapUpdateAvailable) {
|
||||
cachedMapTimestamp = Instant.MIN;
|
||||
}
|
||||
refreshMap(state.svgXPos, state.svgYPos);
|
||||
if (deviceStatus.isActive()) {
|
||||
trackPosition();
|
||||
}
|
||||
|
||||
// When state code changed, refresh cutting times immediately.
|
||||
if (previousStateCode.isPresent() && state.state != previousStateCode.get()) {
|
||||
refreshCuttingTimes();
|
||||
|
||||
// After learning lawn, trigger a forced map refresh on next poll.
|
||||
if (previousStateCode.get() == DeviceStatus.STATE_LEARNING_LAWN) {
|
||||
cachedMapTimestamp = Instant.MIN;
|
||||
int previousState;
|
||||
DeviceStatus previousDeviceStatus;
|
||||
if (previousStateCode.isPresent()) {
|
||||
previousState = previousStateCode.get();
|
||||
previousDeviceStatus = DeviceStatus.fromCode(previousState);
|
||||
if (state.state != previousState
|
||||
&& ((!previousDeviceStatus.isDocked() && deviceStatus.isDocked()) || deviceStatus.isCompleted())) {
|
||||
// When returning to dock or on its way after completing lawn, refresh last cutting time immediately.
|
||||
// We cannot fully rely on completed lawn state since active polling refresh interval is configurable
|
||||
// and we might miss the state if mower returns before next poll.
|
||||
refreshLastCuttingTime();
|
||||
}
|
||||
} else {
|
||||
previousState = state.state;
|
||||
previousDeviceStatus = DeviceStatus.fromCode(previousState);
|
||||
}
|
||||
previousStateCode = Optional.of(state.state);
|
||||
|
||||
refreshOperatingDataConditionally(
|
||||
previousDeviceStatus.isCharging() || deviceStatus.isCharging() || deviceStatus.isActive());
|
||||
|
||||
rescheduleStatePollAccordingToState(deviceStatus);
|
||||
}
|
||||
|
||||
private void rescheduleStatePollAccordingToState(DeviceStatus deviceStatus) {
|
||||
int refreshIntervalSeconds;
|
||||
if (deviceStatus.isActive()) {
|
||||
refreshIntervalSeconds = stateActiveRefreshIntervalSeconds;
|
||||
} else if (deviceStatus.isCharging()) {
|
||||
refreshIntervalSeconds = (int) OPERATING_DATA_ACTIVE_REFRESH_INTERVAL.getSeconds();
|
||||
} else {
|
||||
refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
|
||||
}
|
||||
if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds)) {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshOperatingDataConditionally(boolean isActive)
|
||||
throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
|
||||
// Refresh operating data only occationally or when robot is active/charging.
|
||||
// This will contact the robot directly through cellular network and wake it up
|
||||
// when sleeping.
|
||||
if ((isActive && operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_ACTIVE_REFRESH_INTERVAL)))
|
||||
|| operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_INACTIVE_REFRESH_INTERVAL))) {
|
||||
refreshOperatingData();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshOperatingData()
|
||||
throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
|
||||
updateOperatingData(controller.getOperatingData());
|
||||
operatingDataTimestamp = Instant.now();
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}
|
||||
|
||||
private void refreshCuttingTimesWithExceptionHandling() {
|
||||
try {
|
||||
refreshCuttingTimes();
|
||||
refreshLastCuttingTime();
|
||||
refreshNextCuttingTime();
|
||||
} catch (IndegoAuthenticationException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/offline.comm-error.authentication-failure");
|
||||
@ -286,7 +371,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshCuttingTimes() throws IndegoAuthenticationException, IndegoException {
|
||||
private void refreshLastCuttingTime() throws IndegoAuthenticationException, IndegoException {
|
||||
if (isLinked(LAST_CUTTING)) {
|
||||
Instant lastCutting = controller.getPredictiveLastCutting();
|
||||
if (lastCutting != null) {
|
||||
@ -296,7 +381,20 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
updateState(LAST_CUTTING, UnDefType.UNDEF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshNextCuttingTimeWithExceptionHandling() {
|
||||
try {
|
||||
refreshNextCuttingTime();
|
||||
} 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 refreshNextCuttingTime() throws IndegoAuthenticationException, IndegoException {
|
||||
cancelCuttingTimeRefresh();
|
||||
if (isLinked(NEXT_CUTTING)) {
|
||||
Instant nextCutting = controller.getPredictiveNextCutting();
|
||||
@ -320,12 +418,11 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
}
|
||||
|
||||
private void scheduleCuttingTimesRefresh(Instant nextCutting) {
|
||||
// Schedule additional update right after next planned cutting. This ensures a faster update
|
||||
// in case the next cutting will be postponed (for example due to weather conditions).
|
||||
// Schedule additional update right after next planned cutting. This ensures a faster update.
|
||||
long secondsUntilNextCutting = Instant.now().until(nextCutting, ChronoUnit.SECONDS) + 2;
|
||||
if (secondsUntilNextCutting > 0) {
|
||||
logger.debug("Scheduling fetching of cutting times in {} seconds", secondsUntilNextCutting);
|
||||
this.cuttingTimeFuture = scheduler.schedule(this::refreshCuttingTimesWithExceptionHandling,
|
||||
logger.debug("Scheduling fetching of next cutting time in {} seconds", secondsUntilNextCutting);
|
||||
this.cuttingTimeFuture = scheduler.schedule(this::refreshNextCuttingTimeWithExceptionHandling,
|
||||
secondsUntilNextCutting, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
@ -336,8 +433,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
}
|
||||
RawType cachedMap = this.cachedMap;
|
||||
boolean mapRefreshed;
|
||||
if (cachedMap == null
|
||||
|| cachedMapTimestamp.isBefore(Instant.now().minus(Duration.ofDays(MAP_REFRESH_INTERVAL_DAYS)))) {
|
||||
if (cachedMap == null || cachedMapTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_INTERVAL))) {
|
||||
this.cachedMap = cachedMap = controller.getMap();
|
||||
cachedMapTimestamp = Instant.now();
|
||||
mapRefreshed = true;
|
||||
@ -359,9 +455,23 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
updateState(GARDEN_MAP, new RawType(svgMap.getBytes(), cachedMap.getMimeType()));
|
||||
}
|
||||
|
||||
private void trackPosition() throws IndegoAuthenticationException, IndegoException {
|
||||
if (!isLinked(GARDEN_MAP)) {
|
||||
return;
|
||||
}
|
||||
if (mapRefreshStartedTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_SESSION_DURATION))) {
|
||||
int count = (int) MAP_REFRESH_SESSION_DURATION.getSeconds() / stateActiveRefreshIntervalSeconds + 1;
|
||||
logger.debug("Requesting position updates (count: {}; interval: {}s), previously triggered {}", count,
|
||||
stateActiveRefreshIntervalSeconds, mapRefreshStartedTimestamp);
|
||||
controller.requestPosition(count, stateActiveRefreshIntervalSeconds);
|
||||
mapRefreshStartedTimestamp = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
private void updateState(DeviceStateResponse state) {
|
||||
DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
|
||||
int status = getStatusFromCommand(deviceStatus.getAssociatedCommand());
|
||||
DeviceCommand associatedCommand = deviceStatus.getAssociatedCommand();
|
||||
int status = associatedCommand != null ? getStatusFromCommand(associatedCommand) : 0;
|
||||
int mowed = state.mowed;
|
||||
int error = state.error;
|
||||
int statecode = state.state;
|
||||
@ -390,7 +500,7 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
|
||||
// Mower reported an error
|
||||
if (errorCode != 0) {
|
||||
logger.error("The mower reported an error.");
|
||||
logger.warn("The mower reported an error.");
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -401,35 +511,27 @@ public class BoschIndegoHandler extends BaseThingHandler {
|
||||
}
|
||||
// Can't pause while the mower is docked
|
||||
if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
|
||||
logger.debug("Can't pause the mower while it's docked or docking");
|
||||
logger.info("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(deviceStatus, errorCode)) {
|
||||
logger.debug("The mower is not ready to mow at the moment");
|
||||
logger.info("The mower is not ready to mow at the moment");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int getStatusFromCommand(@Nullable DeviceCommand command) {
|
||||
if (command == null) {
|
||||
return 0;
|
||||
}
|
||||
int status;
|
||||
private int getStatusFromCommand(DeviceCommand command) {
|
||||
switch (command) {
|
||||
case MOW:
|
||||
status = 1;
|
||||
break;
|
||||
return 1;
|
||||
case RETURN:
|
||||
status = 2;
|
||||
break;
|
||||
return 2;
|
||||
case PAUSE:
|
||||
status = 3;
|
||||
break;
|
||||
return 3;
|
||||
default:
|
||||
status = 0;
|
||||
return 0;
|
||||
}
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
@ -14,8 +14,10 @@ thing-type.config.boschindego.indego.cuttingTimeRefresh.label = Cutting Time Ref
|
||||
thing-type.config.boschindego.indego.cuttingTimeRefresh.description = The number of minutes between refreshing last/next cutting time.
|
||||
thing-type.config.boschindego.indego.password.label = Password
|
||||
thing-type.config.boschindego.indego.password.description = Password for the Bosch Indego account.
|
||||
thing-type.config.boschindego.indego.refresh.label = Refresh Interval
|
||||
thing-type.config.boschindego.indego.refresh.description = The number of seconds between refreshing device state.
|
||||
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.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.
|
||||
|
||||
@ -28,7 +30,7 @@ channel-type.boschindego.batteryVoltage.description = Battery voltage reported b
|
||||
channel-type.boschindego.errorcode.label = Error Code
|
||||
channel-type.boschindego.errorcode.description = 0 = no error
|
||||
channel-type.boschindego.gardenMap.label = Garden Map
|
||||
channel-type.boschindego.gardenMap.description = Garden map mapped by the device
|
||||
channel-type.boschindego.gardenMap.description = Garden map created by the device
|
||||
channel-type.boschindego.gardenSize.label = Garden Size
|
||||
channel-type.boschindego.gardenSize.description = Garden size mapped by the device
|
||||
channel-type.boschindego.lastCutting.label = Last Cutting
|
||||
|
@ -34,10 +34,16 @@
|
||||
<description>Password for the Bosch Indego account.</description>
|
||||
</parameter>
|
||||
<parameter name="refresh" type="integer" min="60">
|
||||
<label>Refresh Interval</label>
|
||||
<description>The number of seconds between refreshing device state.</description>
|
||||
<label>Idle Refresh Interval</label>
|
||||
<description>The number of seconds between refreshing device state when idle.</description>
|
||||
<default>180</default>
|
||||
</parameter>
|
||||
<parameter name="stateActiveRefresh" type="integer" min="6">
|
||||
<label>Active Refresh Interval</label>
|
||||
<description>The number of seconds between refreshing device state when active.</description>
|
||||
<advanced>true</advanced>
|
||||
<default>30</default>
|
||||
</parameter>
|
||||
<parameter name="cuttingTimeRefresh" type="integer" min="1">
|
||||
<label>Cutting Time Refresh Interval</label>
|
||||
<description>The number of minutes between refreshing last/next cutting time.</description>
|
||||
@ -165,7 +171,7 @@
|
||||
<channel-type id="gardenMap">
|
||||
<item-type>Image</item-type>
|
||||
<label>Garden Map</label>
|
||||
<description>Garden map mapped by the device</description>
|
||||
<description>Garden map created by the device</description>
|
||||
<state readOnly="true"/>
|
||||
</channel-type>
|
||||
|
||||
|
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 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 org.hamcrest.CoreMatchers.*;
|
||||
import static org.hamcrest.MatcherAssert.assertThat;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link DeviceStatus}.
|
||||
*
|
||||
* @author Jacob Laursen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class DeviceStatusTest {
|
||||
@Test
|
||||
public void unknownIdleStateHasReturnCommand() {
|
||||
assertThat(DeviceStatus.fromCode(256).getAssociatedCommand(), is(DeviceCommand.RETURN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownMowStateHasReturnCommand() {
|
||||
assertThat(DeviceStatus.fromCode(520).getAssociatedCommand(), is(DeviceCommand.MOW));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unknownReturnStateHasReturnCommand() {
|
||||
assertThat(DeviceStatus.fromCode(777).getAssociatedCommand(), is(DeviceCommand.RETURN));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void chargingIsCharging() {
|
||||
assertThat(DeviceStatus.fromCode(257).isCharging(), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void dockedLoadingMapIsActive() {
|
||||
assertThat(DeviceStatus.fromCode(262).isActive(), is(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void lawnCompleteIsCompleted() {
|
||||
assertThat(DeviceStatus.fromCode(775).isCompleted(), is(true));
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user