[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:
Jacob Laursen
2022-07-31 10:30:43 +02:00
committed by GitHub
parent c7f8507cae
commit 2de6dd0310
10 changed files with 434 additions and 107 deletions

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

View File

@@ -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() {

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

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

View File

@@ -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;
}
}

View File

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

View File

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