2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.boschindego.internal.handler;
15 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
17 import java.nio.charset.StandardCharsets;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.time.ZonedDateTime;
21 import java.time.temporal.ChronoUnit;
22 import java.util.Optional;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider;
30 import org.openhab.binding.boschindego.internal.DeviceStatus;
31 import org.openhab.binding.boschindego.internal.IndegoController;
32 import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration;
33 import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
34 import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
35 import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse;
36 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
37 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
38 import org.openhab.binding.boschindego.internal.exceptions.IndegoUnreachableException;
39 import org.openhab.core.i18n.TimeZoneProvider;
40 import org.openhab.core.library.types.DateTimeType;
41 import org.openhab.core.library.types.DecimalType;
42 import org.openhab.core.library.types.OnOffType;
43 import org.openhab.core.library.types.PercentType;
44 import org.openhab.core.library.types.QuantityType;
45 import org.openhab.core.library.types.RawType;
46 import org.openhab.core.library.types.StringType;
47 import org.openhab.core.library.unit.SIUnits;
48 import org.openhab.core.library.unit.Units;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.types.Command;
55 import org.openhab.core.types.RefreshType;
56 import org.openhab.core.types.UnDefType;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
61 * The {@link BoschIndegoHandler} is responsible for handling commands, which are
62 * sent to one of the channels.
64 * @author Jonas Fleck - Initial contribution
65 * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
68 public class BoschIndegoHandler extends BaseThingHandler {
70 private static final String MAP_POSITION_STROKE_COLOR = "#8c8b6d";
71 private static final String MAP_POSITION_FILL_COLOR = "#fff701";
72 private static final int MAP_POSITION_RADIUS = 10;
74 private static final Duration MAP_REFRESH_INTERVAL = Duration.ofDays(1);
75 private static final Duration OPERATING_DATA_INACTIVE_REFRESH_INTERVAL = Duration.ofHours(6);
76 private static final Duration OPERATING_DATA_ACTIVE_REFRESH_INTERVAL = Duration.ofMinutes(2);
77 private static final Duration MAP_REFRESH_SESSION_DURATION = Duration.ofMinutes(5);
78 private static final Duration COMMAND_STATE_REFRESH_TIMEOUT = Duration.ofSeconds(10);
80 private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
81 private final HttpClient httpClient;
82 private final BoschIndegoTranslationProvider translationProvider;
83 private final TimeZoneProvider timeZoneProvider;
85 private @NonNullByDefault({}) IndegoController controller;
86 private @Nullable ScheduledFuture<?> statePollFuture;
87 private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
88 private @Nullable ScheduledFuture<?> cuttingTimeFuture;
89 private boolean propertiesInitialized;
90 private Optional<Integer> previousStateCode = Optional.empty();
91 private @Nullable RawType cachedMap;
92 private Instant cachedMapTimestamp = Instant.MIN;
93 private Instant operatingDataTimestamp = Instant.MIN;
94 private Instant mapRefreshStartedTimestamp = Instant.MIN;
95 private int stateInactiveRefreshIntervalSeconds;
96 private int stateActiveRefreshIntervalSeconds;
97 private int currentRefreshIntervalSeconds;
99 public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
100 TimeZoneProvider timeZoneProvider) {
102 this.httpClient = httpClient;
103 this.translationProvider = translationProvider;
104 this.timeZoneProvider = timeZoneProvider;
108 public void initialize() {
109 logger.debug("Initializing Indego handler");
110 BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
111 stateInactiveRefreshIntervalSeconds = (int) config.refresh;
112 stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh;
113 String username = config.username;
114 String password = config.password;
116 if (username == null || username.isBlank()) {
117 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
118 "@text/offline.conf-error.missing-username");
121 if (password == null || password.isBlank()) {
122 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
123 "@text/offline.conf-error.missing-password");
127 controller = new IndegoController(httpClient, username, password);
129 updateStatus(ThingStatus.UNKNOWN);
130 previousStateCode = Optional.empty();
131 rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds);
132 this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
133 config.cuttingTimeRefresh, TimeUnit.MINUTES);
136 private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds) {
137 ScheduledFuture<?> statePollFuture = this.statePollFuture;
138 if (statePollFuture != null) {
139 if (refreshIntervalSeconds == currentRefreshIntervalSeconds) {
143 statePollFuture.cancel(false);
145 logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
147 this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateWithExceptionHandling, delaySeconds,
148 refreshIntervalSeconds, TimeUnit.SECONDS);
149 currentRefreshIntervalSeconds = refreshIntervalSeconds;
155 public void dispose() {
156 logger.debug("Disposing Indego handler");
157 ScheduledFuture<?> pollFuture = this.statePollFuture;
158 if (pollFuture != null) {
159 pollFuture.cancel(true);
161 this.statePollFuture = null;
162 pollFuture = this.cuttingTimePollFuture;
163 if (pollFuture != null) {
164 pollFuture.cancel(true);
166 this.cuttingTimePollFuture = null;
167 pollFuture = this.cuttingTimeFuture;
168 if (pollFuture != null) {
169 pollFuture.cancel(true);
171 this.cuttingTimeFuture = null;
173 scheduler.execute(() -> {
175 controller.deauthenticate();
176 } catch (IndegoException e) {
177 logger.debug("Deauthentication failed", e);
183 public void handleCommand(ChannelUID channelUID, Command command) {
184 logger.debug("handleCommand {} for channel {}", command, channelUID);
186 if (command == RefreshType.REFRESH) {
187 handleRefreshCommand(channelUID.getId());
190 if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
191 sendCommand(((DecimalType) command).intValue());
193 } catch (IndegoAuthenticationException e) {
194 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
195 "@text/offline.comm-error.authentication-failure");
196 } catch (IndegoUnreachableException e) {
197 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
198 "@text/offline.comm-error.unreachable");
199 } catch (IndegoException e) {
200 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
204 private void handleRefreshCommand(String channelId)
205 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
208 // Force map refresh and fall through to state update.
209 cachedMapTimestamp = Instant.MIN;
219 refreshLastCuttingTime();
222 refreshNextCuttingTime();
226 case BATTERY_VOLTAGE:
227 case BATTERY_TEMPERATURE:
229 refreshOperatingData();
234 private void sendCommand(int commandInt) throws IndegoException {
235 DeviceCommand command;
236 switch (commandInt) {
238 command = DeviceCommand.MOW;
241 command = DeviceCommand.RETURN;
244 command = DeviceCommand.PAUSE;
247 logger.warn("Invalid command {}", commandInt);
251 DeviceStateResponse state = controller.getState();
252 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
253 if (!verifyCommand(command, deviceStatus, state.error)) {
256 logger.debug("Sending command {}", command);
257 controller.sendCommand(command);
259 // State is not updated immediately, so await new state for some seconds.
260 // For command MOW, state will shortly be updated to 262 (docked, loading map).
261 // This is considered "active", so after this state change, polling frequency will
262 // be increased for faster updates.
263 DeviceStateResponse stateResponse = controller.getState(COMMAND_STATE_REFRESH_TIMEOUT);
264 if (stateResponse.state != 0) {
265 updateState(stateResponse);
266 deviceStatus = DeviceStatus.fromCode(stateResponse.state);
267 rescheduleStatePollAccordingToState(deviceStatus);
271 private void refreshStateWithExceptionHandling() {
274 } catch (IndegoAuthenticationException e) {
275 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
276 "@text/offline.comm-error.authentication-failure");
277 } catch (IndegoUnreachableException e) {
278 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
279 "@text/offline.comm-error.unreachable");
280 } catch (IndegoException e) {
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
285 private void refreshState() throws IndegoAuthenticationException, IndegoException {
286 if (!propertiesInitialized) {
287 getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
288 propertiesInitialized = true;
291 DeviceStateResponse state = controller.getState();
292 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
295 // Update map and start tracking positions if mower is active.
296 if (state.mapUpdateAvailable) {
297 cachedMapTimestamp = Instant.MIN;
299 refreshMap(state.svgXPos, state.svgYPos);
300 if (deviceStatus.isActive()) {
305 DeviceStatus previousDeviceStatus;
306 if (previousStateCode.isPresent()) {
307 previousState = previousStateCode.get();
308 previousDeviceStatus = DeviceStatus.fromCode(previousState);
309 if (state.state != previousState
310 && ((!previousDeviceStatus.isDocked() && deviceStatus.isDocked()) || deviceStatus.isCompleted())) {
311 // When returning to dock or on its way after completing lawn, refresh last cutting time immediately.
312 // We cannot fully rely on completed lawn state since active polling refresh interval is configurable
313 // and we might miss the state if mower returns before next poll.
314 refreshLastCuttingTime();
317 previousState = state.state;
318 previousDeviceStatus = DeviceStatus.fromCode(previousState);
320 previousStateCode = Optional.of(state.state);
322 refreshOperatingDataConditionally(
323 previousDeviceStatus.isCharging() || deviceStatus.isCharging() || deviceStatus.isActive());
325 rescheduleStatePollAccordingToState(deviceStatus);
328 private void rescheduleStatePollAccordingToState(DeviceStatus deviceStatus) {
329 int refreshIntervalSeconds;
330 if (deviceStatus.isActive()) {
331 refreshIntervalSeconds = stateActiveRefreshIntervalSeconds;
332 } else if (deviceStatus.isCharging()) {
333 refreshIntervalSeconds = (int) OPERATING_DATA_ACTIVE_REFRESH_INTERVAL.getSeconds();
335 refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
337 if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds)) {
338 // After job has been rescheduled, request operating data one last time on next poll.
339 // This is needed to update battery values after a charging cycle has completed.
340 operatingDataTimestamp = Instant.MIN;
344 private void refreshOperatingDataConditionally(boolean isActive)
345 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
346 // Refresh operating data only occationally or when robot is active/charging.
347 // This will contact the robot directly through cellular network and wake it up
349 if ((isActive && operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_ACTIVE_REFRESH_INTERVAL)))
350 || operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_INACTIVE_REFRESH_INTERVAL))) {
351 refreshOperatingData();
355 private void refreshOperatingData()
356 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
357 updateOperatingData(controller.getOperatingData());
358 operatingDataTimestamp = Instant.now();
359 updateStatus(ThingStatus.ONLINE);
362 private void refreshCuttingTimesWithExceptionHandling() {
364 refreshLastCuttingTime();
365 refreshNextCuttingTime();
366 } catch (IndegoAuthenticationException e) {
367 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
368 "@text/offline.comm-error.authentication-failure");
369 } catch (IndegoException e) {
370 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
374 private void refreshLastCuttingTime() throws IndegoAuthenticationException, IndegoException {
375 if (isLinked(LAST_CUTTING)) {
376 Instant lastCutting = controller.getPredictiveLastCutting();
377 if (lastCutting != null) {
378 updateState(LAST_CUTTING,
379 new DateTimeType(ZonedDateTime.ofInstant(lastCutting, timeZoneProvider.getTimeZone())));
381 updateState(LAST_CUTTING, UnDefType.UNDEF);
386 private void refreshNextCuttingTimeWithExceptionHandling() {
388 refreshNextCuttingTime();
389 } catch (IndegoAuthenticationException e) {
390 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
391 "@text/offline.comm-error.authentication-failure");
392 } catch (IndegoException e) {
393 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
397 private void refreshNextCuttingTime() throws IndegoAuthenticationException, IndegoException {
398 cancelCuttingTimeRefresh();
399 if (isLinked(NEXT_CUTTING)) {
400 Instant nextCutting = controller.getPredictiveNextCutting();
401 if (nextCutting != null) {
402 updateState(NEXT_CUTTING,
403 new DateTimeType(ZonedDateTime.ofInstant(nextCutting, timeZoneProvider.getTimeZone())));
404 scheduleCuttingTimesRefresh(nextCutting);
406 updateState(NEXT_CUTTING, UnDefType.UNDEF);
411 private void cancelCuttingTimeRefresh() {
412 ScheduledFuture<?> cuttingTimeFuture = this.cuttingTimeFuture;
413 if (cuttingTimeFuture != null) {
414 // Do not interrupt as we might be running within that job.
415 cuttingTimeFuture.cancel(false);
416 this.cuttingTimeFuture = null;
420 private void scheduleCuttingTimesRefresh(Instant nextCutting) {
421 // Schedule additional update right after next planned cutting. This ensures a faster update.
422 long secondsUntilNextCutting = Instant.now().until(nextCutting, ChronoUnit.SECONDS) + 2;
423 if (secondsUntilNextCutting > 0) {
424 logger.debug("Scheduling fetching of next cutting time in {} seconds", secondsUntilNextCutting);
425 this.cuttingTimeFuture = scheduler.schedule(this::refreshNextCuttingTimeWithExceptionHandling,
426 secondsUntilNextCutting, TimeUnit.SECONDS);
430 private void refreshMap(int xPos, int yPos) throws IndegoAuthenticationException, IndegoException {
431 if (!isLinked(GARDEN_MAP)) {
434 RawType cachedMap = this.cachedMap;
435 boolean mapRefreshed;
436 if (cachedMap == null || cachedMapTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_INTERVAL))) {
437 this.cachedMap = cachedMap = controller.getMap();
438 cachedMapTimestamp = Instant.now();
441 mapRefreshed = false;
443 String svgMap = new String(cachedMap.getBytes(), StandardCharsets.UTF_8);
444 if (!svgMap.endsWith("</svg>")) {
446 logger.warn("Unexpected map format, unable to plot location");
447 logger.trace("Received map: {}", svgMap);
448 updateState(GARDEN_MAP, cachedMap);
452 svgMap = svgMap.substring(0, svgMap.length() - 6) + "<circle cx=\"" + xPos + "\" cy=\"" + yPos + "\" r=\""
453 + MAP_POSITION_RADIUS + "\" stroke=\"" + MAP_POSITION_STROKE_COLOR + "\" fill=\""
454 + MAP_POSITION_FILL_COLOR + "\" />\n</svg>";
455 updateState(GARDEN_MAP, new RawType(svgMap.getBytes(), cachedMap.getMimeType()));
458 private void trackPosition() throws IndegoAuthenticationException, IndegoException {
459 if (!isLinked(GARDEN_MAP)) {
462 if (mapRefreshStartedTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_SESSION_DURATION))) {
463 int count = (int) MAP_REFRESH_SESSION_DURATION.getSeconds() / stateActiveRefreshIntervalSeconds + 1;
464 logger.debug("Requesting position updates (count: {}; interval: {}s), previously triggered {}", count,
465 stateActiveRefreshIntervalSeconds, mapRefreshStartedTimestamp);
466 controller.requestPosition(count, stateActiveRefreshIntervalSeconds);
467 mapRefreshStartedTimestamp = Instant.now();
471 private void updateState(DeviceStateResponse state) {
472 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
473 DeviceCommand associatedCommand = deviceStatus.getAssociatedCommand();
474 int status = associatedCommand != null ? getStatusFromCommand(associatedCommand) : 0;
475 int mowed = state.mowed;
476 int error = state.error;
477 int statecode = state.state;
478 boolean ready = isReadyToMow(deviceStatus, state.error);
480 updateState(STATECODE, new DecimalType(statecode));
481 updateState(READY, new DecimalType(ready ? 1 : 0));
482 updateState(ERRORCODE, new DecimalType(error));
483 updateState(MOWED, new PercentType(mowed));
484 updateState(STATE, new DecimalType(status));
485 updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage(translationProvider)));
488 private void updateOperatingData(OperatingDataResponse operatingData) {
489 updateState(BATTERY_VOLTAGE, new QuantityType<>(operatingData.battery.voltage, Units.VOLT));
490 updateState(BATTERY_LEVEL, new DecimalType(operatingData.battery.percent));
491 updateState(LOW_BATTERY, OnOffType.from(operatingData.battery.percent < 20));
492 updateState(BATTERY_TEMPERATURE, new QuantityType<>(operatingData.battery.batteryTemperature, SIUnits.CELSIUS));
493 updateState(GARDEN_SIZE, new QuantityType<>(operatingData.garden.size, SIUnits.SQUARE_METRE));
496 private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
497 return deviceStatus.isReadyToMow() && error == 0;
500 private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
501 // Mower reported an error
502 if (errorCode != 0) {
503 logger.warn("The mower reported an error.");
507 // Command is equal to current state
508 if (command == deviceStatus.getAssociatedCommand()) {
509 logger.debug("Command is equal to state");
512 // Can't pause while the mower is docked
513 if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
514 logger.info("Can't pause the mower while it's docked or docking");
517 // Command means "MOW" but mower is not ready
518 if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
519 logger.info("The mower is not ready to mow at the moment");
525 private int getStatusFromCommand(DeviceCommand command) {