]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschindego] Provide faster channel updates (#13192)
authorJacob Laursen <jacob-github@vindvejr.dk>
Sun, 31 Jul 2022 08:30:43 +0000 (10:30 +0200)
committerGitHub <noreply@github.com>
Sun, 31 Jul 2022 08:30:43 +0000 (10:30 +0200)
* 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>
bundles/org.openhab.binding.boschindego/README.md
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStateAttribute.java [new file with mode: 0644]
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStatus.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/IndegoController.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/config/BoschIndegoConfiguration.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/dto/response/DeviceStateResponse.java
bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/handler/BoschIndegoHandler.java
bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/i18n/boschindego.properties
bundles/org.openhab.binding.boschindego/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.boschindego/src/test/java/org/openhab/binding/boschindego/internal/DeviceStatusTest.java [new file with mode: 0644]

index 0f983e0cf609e7cae79a4028a9c70687814be53a..62b9dd3c10a3b9fa4fba24f13560eade07f7ef4b 100644 (file)
@@ -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
 
diff --git a/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStateAttribute.java b/bundles/org.openhab.binding.boschindego/src/main/java/org/openhab/binding/boschindego/internal/DeviceStateAttribute.java
new file mode 100644 (file)
index 0000000..7a147e8
--- /dev/null
@@ -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
+}
index e6008e4f9e57642572bcc7a6fb4dec053cb8220c..4a89da90bb5372400e4c18cb386f95d9a03fa210 100644 (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() {
index 4c77d8a6d1ccc49c9b4b4f2ca570bf5cb4508fc8..eeb9cde6a87bf9fe89e03e84253adbbb2518c010 100644 (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);
+    }
 }
index 03148db98a933567031aa5a2119be3d4192badb6..c7ed17a5f965ad0f46f4c994169c98181de0a47a 100644 (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;
 }
index b8e4a9f278ab53284ed2bedc5dd7ae2707763465..20bfccaa37b46195da6c24705d80a2e6a40340f7 100644 (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;
 
index 3500ac0477bf93bc1096d5623d89d8100887dc12..d4f611ace6eb018741f560217bddb8649c27b743 100644 (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;
     }
 }
index fd985085e3c675ba0464c4fb262cbd5542d179a1..264197d847b34bc4f7d0933b93938bfc5d59b4ab 100644 (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
index b4d87a017daaebe694cd61beabbd1a4faa7dfc0b..61386647aacd587a4378b6414873389d9cc2f93c 100644 (file)
                                <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>
        <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>
 
diff --git a/bundles/org.openhab.binding.boschindego/src/test/java/org/openhab/binding/boschindego/internal/DeviceStatusTest.java b/bundles/org.openhab.binding.boschindego/src/test/java/org/openhab/binding/boschindego/internal/DeviceStatusTest.java
new file mode 100644 (file)
index 0000000..ca35e92
--- /dev/null
@@ -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));
+    }
+}