2 * Copyright (c) 2010-2023 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.IndegoDeviceController;
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.IndegoInvalidCommandException;
39 import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
40 import org.openhab.core.auth.client.oauth2.OAuthClientService;
41 import org.openhab.core.i18n.TimeZoneProvider;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.OnOffType;
45 import org.openhab.core.library.types.PercentType;
46 import org.openhab.core.library.types.QuantityType;
47 import org.openhab.core.library.types.RawType;
48 import org.openhab.core.library.types.StringType;
49 import org.openhab.core.library.unit.SIUnits;
50 import org.openhab.core.library.unit.Units;
51 import org.openhab.core.thing.Bridge;
52 import org.openhab.core.thing.ChannelUID;
53 import org.openhab.core.thing.Thing;
54 import org.openhab.core.thing.ThingStatus;
55 import org.openhab.core.thing.ThingStatusDetail;
56 import org.openhab.core.thing.ThingStatusInfo;
57 import org.openhab.core.thing.binding.BaseThingHandler;
58 import org.openhab.core.thing.binding.ThingHandler;
59 import org.openhab.core.types.Command;
60 import org.openhab.core.types.RefreshType;
61 import org.openhab.core.types.UnDefType;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
66 * The {@link BoschIndegoHandler} is responsible for handling commands, which are
67 * sent to one of the channels.
69 * @author Jonas Fleck - Initial contribution
70 * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
73 public class BoschIndegoHandler extends BaseThingHandler {
75 private static final String MAP_POSITION_STROKE_COLOR = "#8c8b6d";
76 private static final String MAP_POSITION_FILL_COLOR = "#fff701";
77 private static final int MAP_POSITION_RADIUS = 10;
79 private static final Duration MAP_REFRESH_INTERVAL = Duration.ofDays(1);
80 private static final Duration OPERATING_DATA_INACTIVE_REFRESH_INTERVAL = Duration.ofHours(6);
81 private static final Duration OPERATING_DATA_OFFLINE_REFRESH_INTERVAL = Duration.ofMinutes(30);
82 private static final Duration OPERATING_DATA_ACTIVE_REFRESH_INTERVAL = Duration.ofMinutes(2);
83 private static final Duration MAP_REFRESH_SESSION_DURATION = Duration.ofMinutes(5);
84 private static final Duration COMMAND_STATE_REFRESH_TIMEOUT = Duration.ofSeconds(10);
86 private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
87 private final HttpClient httpClient;
88 private final BoschIndegoTranslationProvider translationProvider;
89 private final TimeZoneProvider timeZoneProvider;
91 private @NonNullByDefault({}) OAuthClientService oAuthClientService;
92 private @NonNullByDefault({}) IndegoDeviceController controller;
93 private @Nullable ScheduledFuture<?> statePollFuture;
94 private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
95 private @Nullable ScheduledFuture<?> cuttingTimeFuture;
96 private Optional<Integer> previousStateCode = Optional.empty();
97 private @Nullable RawType cachedMap;
98 private Instant cachedMapTimestamp = Instant.MIN;
99 private Instant operatingDataTimestamp = Instant.MIN;
100 private Instant mapRefreshStartedTimestamp = Instant.MIN;
101 private ThingStatus lastOperatingDataStatus = ThingStatus.UNINITIALIZED;
102 private int stateInactiveRefreshIntervalSeconds;
103 private int stateActiveRefreshIntervalSeconds;
104 private int currentRefreshIntervalSeconds;
106 public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
107 TimeZoneProvider timeZoneProvider) {
109 this.httpClient = httpClient;
110 this.translationProvider = translationProvider;
111 this.timeZoneProvider = timeZoneProvider;
115 public void initialize() {
116 BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
117 stateInactiveRefreshIntervalSeconds = (int) config.refresh;
118 stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh;
120 Bridge bridge = getBridge();
121 if (bridge == null) {
122 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
123 "@text/offline.conf-error.missing-bridge");
127 ThingHandler handler = bridge.getHandler();
128 if (handler instanceof BoschAccountHandler accountHandler) {
129 this.oAuthClientService = accountHandler.getOAuthClientService();
131 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
132 "@text/offline.conf-error.missing-bridge");
136 this.updateProperty(Thing.PROPERTY_SERIAL_NUMBER, config.serialNumber);
138 controller = new IndegoDeviceController(httpClient, oAuthClientService, config.serialNumber);
140 updateStatus(ThingStatus.UNKNOWN);
141 previousStateCode = Optional.empty();
142 rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, false);
143 this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
144 config.cuttingTimeRefresh, TimeUnit.MINUTES);
148 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
149 if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE
150 && getThing().getStatusInfo().getStatus() == ThingStatus.OFFLINE) {
151 // Trigger immediate state refresh upon authorization success.
152 rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, true);
153 } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
158 private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds, boolean force) {
159 ScheduledFuture<?> statePollFuture = this.statePollFuture;
160 if (statePollFuture != null) {
161 if (!force && refreshIntervalSeconds == currentRefreshIntervalSeconds) {
165 statePollFuture.cancel(force);
167 logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
169 this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateWithExceptionHandling, delaySeconds,
170 refreshIntervalSeconds, TimeUnit.SECONDS);
171 currentRefreshIntervalSeconds = refreshIntervalSeconds;
177 public void dispose() {
178 ScheduledFuture<?> pollFuture = this.statePollFuture;
179 if (pollFuture != null) {
180 pollFuture.cancel(true);
182 this.statePollFuture = null;
183 pollFuture = this.cuttingTimePollFuture;
184 if (pollFuture != null) {
185 pollFuture.cancel(true);
187 this.cuttingTimePollFuture = null;
188 pollFuture = this.cuttingTimeFuture;
189 if (pollFuture != null) {
190 pollFuture.cancel(true);
192 this.cuttingTimeFuture = null;
196 public void handleCommand(ChannelUID channelUID, Command command) {
197 logger.debug("handleCommand {} for channel {}", command, channelUID);
199 if (command == RefreshType.REFRESH) {
200 handleRefreshCommand(channelUID.getId());
203 if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
204 sendCommand(((DecimalType) command).intValue());
206 } catch (IndegoAuthenticationException e) {
207 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
208 "@text/offline.comm-error.authentication-failure");
209 } catch (IndegoTimeoutException e) {
210 updateStatus(lastOperatingDataStatus = ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
211 "@text/offline.comm-error.unreachable");
212 } catch (IndegoInvalidCommandException e) {
213 logger.warn("Invalid command: {}", e.getMessage());
214 if (e.hasErrorCode()) {
215 updateState(ERRORCODE, new DecimalType(e.getErrorCode()));
217 } catch (IndegoException e) {
218 logger.warn("Command failed: {}", e.getMessage());
222 private void handleRefreshCommand(String channelId)
223 throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
226 // Force map refresh and fall through to state update.
227 cachedMapTimestamp = Instant.MIN;
237 refreshLastCuttingTime();
240 refreshNextCuttingTime();
244 case BATTERY_VOLTAGE:
245 case BATTERY_TEMPERATURE:
247 refreshOperatingData();
252 private void sendCommand(int commandInt) throws IndegoException {
253 DeviceCommand command;
254 switch (commandInt) {
256 command = DeviceCommand.MOW;
259 command = DeviceCommand.RETURN;
262 command = DeviceCommand.PAUSE;
265 logger.warn("Invalid command {}", commandInt);
269 DeviceStateResponse state = controller.getState();
270 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
271 if (!verifyCommand(command, deviceStatus, state.error)) {
274 logger.debug("Sending command {}", command);
275 controller.sendCommand(command);
277 // State is not updated immediately, so await new state for some seconds.
278 // For command MOW, state will shortly be updated to 262 (docked, loading map).
279 // This is considered "active", so after this state change, polling frequency will
280 // be increased for faster updates.
281 DeviceStateResponse stateResponse = controller.getState(COMMAND_STATE_REFRESH_TIMEOUT);
282 if (stateResponse.state != 0) {
283 updateState(stateResponse);
284 deviceStatus = DeviceStatus.fromCode(stateResponse.state);
285 rescheduleStatePollAccordingToState(deviceStatus);
289 private void refreshStateWithExceptionHandling() {
292 } catch (IndegoAuthenticationException e) {
293 logger.warn("Failed to authenticate: {}", e.getMessage());
294 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
295 "@text/offline.comm-error.authentication-failure");
296 } catch (IndegoTimeoutException e) {
297 updateStatus(lastOperatingDataStatus = ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
298 "@text/offline.comm-error.unreachable");
299 } catch (IndegoException e) {
300 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
304 private void refreshState() throws IndegoAuthenticationException, IndegoException {
305 DeviceStateResponse state = controller.getState();
306 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
309 // Update map and start tracking positions if mower is active.
310 if (state.mapUpdateAvailable) {
311 cachedMapTimestamp = Instant.MIN;
313 refreshMap(state.svgXPos, state.svgYPos);
314 if (deviceStatus.isActive()) {
319 DeviceStatus previousDeviceStatus;
320 if (previousStateCode.isPresent()) {
321 previousState = previousStateCode.get();
322 previousDeviceStatus = DeviceStatus.fromCode(previousState);
323 if (state.state != previousState
324 && ((!previousDeviceStatus.isDocked() && deviceStatus.isDocked()) || deviceStatus.isCompleted())) {
325 // When returning to dock or on its way after completing lawn, refresh last cutting time immediately.
326 // We cannot fully rely on completed lawn state since active polling refresh interval is configurable
327 // and we might miss the state if mower returns before next poll.
328 refreshLastCuttingTime();
331 previousState = state.state;
332 previousDeviceStatus = DeviceStatus.fromCode(previousState);
334 previousStateCode = Optional.of(state.state);
336 refreshOperatingDataConditionally(
337 previousDeviceStatus.isCharging() || deviceStatus.isCharging() || deviceStatus.isActive());
339 if (lastOperatingDataStatus == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) {
340 // Revert temporary offline status caused by disruptions other than unreachable device.
341 updateStatus(ThingStatus.ONLINE);
342 } else if (lastOperatingDataStatus == ThingStatus.OFFLINE) {
343 // Update description to reflect why thing is still offline.
344 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
345 "@text/offline.comm-error.unreachable");
348 rescheduleStatePollAccordingToState(deviceStatus);
351 private void rescheduleStatePollAccordingToState(DeviceStatus deviceStatus) {
352 int refreshIntervalSeconds;
353 if (deviceStatus.isActive()) {
354 refreshIntervalSeconds = stateActiveRefreshIntervalSeconds;
355 } else if (deviceStatus.isCharging()) {
356 refreshIntervalSeconds = (int) OPERATING_DATA_ACTIVE_REFRESH_INTERVAL.getSeconds();
358 refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
360 if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds, false)) {
361 // After job has been rescheduled, request operating data one last time on next poll.
362 // This is needed to update battery values after a charging cycle has completed.
363 operatingDataTimestamp = Instant.MIN;
367 private void refreshOperatingDataConditionally(boolean isActive)
368 throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
369 // Refresh operating data only occationally or when robot is active/charging.
370 // This will contact the robot directly through cellular network and wake it up
371 // when sleeping. Additionally, refresh more often after being offline to try to get
372 // back online as soon as possible without putting too much stress on the service.
373 if ((isActive && operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_ACTIVE_REFRESH_INTERVAL)))
374 || (lastOperatingDataStatus != ThingStatus.ONLINE && operatingDataTimestamp
375 .isBefore(Instant.now().minus(OPERATING_DATA_OFFLINE_REFRESH_INTERVAL)))
376 || operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_INACTIVE_REFRESH_INTERVAL))) {
377 refreshOperatingData();
381 private void refreshOperatingData() throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
382 updateOperatingData(controller.getOperatingData());
383 operatingDataTimestamp = Instant.now();
384 updateStatus(lastOperatingDataStatus = ThingStatus.ONLINE);
387 private void refreshCuttingTimesWithExceptionHandling() {
389 refreshLastCuttingTime();
390 refreshNextCuttingTime();
391 } catch (IndegoAuthenticationException e) {
392 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
393 "@text/offline.comm-error.authentication-failure");
394 } catch (IndegoException e) {
395 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
399 private void refreshLastCuttingTime() throws IndegoAuthenticationException, IndegoException {
400 if (isLinked(LAST_CUTTING)) {
401 Instant lastCutting = controller.getPredictiveLastCutting();
402 if (lastCutting != null) {
403 updateState(LAST_CUTTING,
404 new DateTimeType(ZonedDateTime.ofInstant(lastCutting, timeZoneProvider.getTimeZone())));
406 updateState(LAST_CUTTING, UnDefType.UNDEF);
411 private void refreshNextCuttingTimeWithExceptionHandling() {
413 refreshNextCuttingTime();
414 } catch (IndegoAuthenticationException e) {
415 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
416 "@text/offline.comm-error.authentication-failure");
417 } catch (IndegoException e) {
418 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
422 private void refreshNextCuttingTime() throws IndegoAuthenticationException, IndegoException {
423 cancelCuttingTimeRefresh();
424 if (isLinked(NEXT_CUTTING)) {
425 Instant nextCutting = controller.getPredictiveNextCutting();
426 if (nextCutting != null) {
427 updateState(NEXT_CUTTING,
428 new DateTimeType(ZonedDateTime.ofInstant(nextCutting, timeZoneProvider.getTimeZone())));
429 scheduleCuttingTimesRefresh(nextCutting);
431 updateState(NEXT_CUTTING, UnDefType.UNDEF);
436 private void cancelCuttingTimeRefresh() {
437 ScheduledFuture<?> cuttingTimeFuture = this.cuttingTimeFuture;
438 if (cuttingTimeFuture != null) {
439 // Do not interrupt as we might be running within that job.
440 cuttingTimeFuture.cancel(false);
441 this.cuttingTimeFuture = null;
445 private void scheduleCuttingTimesRefresh(Instant nextCutting) {
446 // Schedule additional update right after next planned cutting. This ensures a faster update.
447 long secondsUntilNextCutting = Instant.now().until(nextCutting, ChronoUnit.SECONDS) + 2;
448 if (secondsUntilNextCutting > 0) {
449 logger.debug("Scheduling fetching of next cutting time in {} seconds", secondsUntilNextCutting);
450 this.cuttingTimeFuture = scheduler.schedule(this::refreshNextCuttingTimeWithExceptionHandling,
451 secondsUntilNextCutting, TimeUnit.SECONDS);
455 private void refreshMap(int xPos, int yPos) throws IndegoAuthenticationException, IndegoException {
456 if (!isLinked(GARDEN_MAP)) {
459 RawType cachedMap = this.cachedMap;
460 boolean mapRefreshed;
461 if (cachedMap == null || cachedMapTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_INTERVAL))) {
462 this.cachedMap = cachedMap = controller.getMap();
463 cachedMapTimestamp = Instant.now();
466 mapRefreshed = false;
468 String svgMap = new String(cachedMap.getBytes(), StandardCharsets.UTF_8);
469 if (!svgMap.endsWith("</svg>")) {
471 logger.warn("Unexpected map format, unable to plot location");
472 logger.trace("Received map: {}", svgMap);
473 updateState(GARDEN_MAP, cachedMap);
477 svgMap = svgMap.substring(0, svgMap.length() - 6) + "<circle cx=\"" + xPos + "\" cy=\"" + yPos + "\" r=\""
478 + MAP_POSITION_RADIUS + "\" stroke=\"" + MAP_POSITION_STROKE_COLOR + "\" fill=\""
479 + MAP_POSITION_FILL_COLOR + "\" />\n</svg>";
480 updateState(GARDEN_MAP, new RawType(svgMap.getBytes(), cachedMap.getMimeType()));
483 private void trackPosition() throws IndegoAuthenticationException, IndegoException {
484 if (!isLinked(GARDEN_MAP)) {
487 if (mapRefreshStartedTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_SESSION_DURATION))) {
488 int count = (int) MAP_REFRESH_SESSION_DURATION.getSeconds() / stateActiveRefreshIntervalSeconds + 1;
489 logger.debug("Requesting position updates (count: {}; interval: {}s), previously triggered {}", count,
490 stateActiveRefreshIntervalSeconds, mapRefreshStartedTimestamp);
491 controller.requestPosition(count, stateActiveRefreshIntervalSeconds);
492 mapRefreshStartedTimestamp = Instant.now();
496 private void updateState(DeviceStateResponse state) {
497 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
498 DeviceCommand associatedCommand = deviceStatus.getAssociatedCommand();
499 int status = associatedCommand != null ? getStatusFromCommand(associatedCommand) : 0;
500 int mowed = state.mowed;
501 int error = state.error;
502 int statecode = state.state;
503 boolean ready = isReadyToMow(deviceStatus, state.error);
505 updateState(STATECODE, new DecimalType(statecode));
506 updateState(READY, new DecimalType(ready ? 1 : 0));
507 updateState(ERRORCODE, new DecimalType(error));
508 updateState(MOWED, new PercentType(mowed));
509 updateState(STATE, new DecimalType(status));
510 updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage(translationProvider)));
513 private void updateOperatingData(OperatingDataResponse operatingData) {
514 updateState(BATTERY_VOLTAGE, new QuantityType<>(operatingData.battery.voltage, Units.VOLT));
515 updateState(BATTERY_LEVEL, new DecimalType(operatingData.battery.percent));
516 updateState(LOW_BATTERY, OnOffType.from(operatingData.battery.percent < 20));
517 updateState(BATTERY_TEMPERATURE, new QuantityType<>(operatingData.battery.batteryTemperature, SIUnits.CELSIUS));
518 updateState(GARDEN_SIZE, new QuantityType<>(operatingData.garden.size, SIUnits.SQUARE_METRE));
521 private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
522 return deviceStatus.isReadyToMow() && error == 0;
525 private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
526 // Mower reported an error
527 if (errorCode != 0) {
528 logger.warn("The mower reported an error.");
532 // Command is equal to current state
533 if (command == deviceStatus.getAssociatedCommand()) {
534 logger.debug("Command is equal to state");
537 // Can't pause while the mower is docked
538 if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
539 logger.info("Can't pause the mower while it's docked or docking");
542 // Command means "MOW" but mower is not ready
543 if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
544 logger.info("The mower is not ready to mow at the moment");
550 private int getStatusFromCommand(DeviceCommand command) {