[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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user