]> git.basschouten.com Git - openhab-addons.git/blob
e7f2b9768a3dd9b296b70ed5f90b58b6ef8a3642
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.boschindego.internal.handler;
14
15 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
16
17 import java.nio.charset.StandardCharsets;
18 import java.time.Duration;
19 import java.time.Instant;
20 import java.time.LocalDateTime;
21 import java.time.ZonedDateTime;
22 import java.time.temporal.ChronoUnit;
23 import java.util.Map;
24 import java.util.Optional;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.eclipse.jetty.client.HttpClient;
31 import org.openhab.binding.boschindego.internal.AuthorizationListener;
32 import org.openhab.binding.boschindego.internal.AuthorizationProvider;
33 import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider;
34 import org.openhab.binding.boschindego.internal.DeviceStatus;
35 import org.openhab.binding.boschindego.internal.IndegoDeviceController;
36 import org.openhab.binding.boschindego.internal.IndegoTypeDatabase;
37 import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration;
38 import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
39 import org.openhab.binding.boschindego.internal.dto.response.DevicePropertiesResponse;
40 import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
41 import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse;
42 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
43 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
44 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
45 import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
46 import org.openhab.core.i18n.TimeZoneProvider;
47 import org.openhab.core.library.types.DateTimeType;
48 import org.openhab.core.library.types.DecimalType;
49 import org.openhab.core.library.types.OnOffType;
50 import org.openhab.core.library.types.PercentType;
51 import org.openhab.core.library.types.QuantityType;
52 import org.openhab.core.library.types.RawType;
53 import org.openhab.core.library.types.StringType;
54 import org.openhab.core.library.unit.SIUnits;
55 import org.openhab.core.library.unit.Units;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.ThingStatusInfo;
62 import org.openhab.core.thing.binding.BaseThingHandler;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.openhab.core.types.UnDefType;
66 import org.slf4j.Logger;
67 import org.slf4j.LoggerFactory;
68
69 /**
70  * The {@link BoschIndegoHandler} is responsible for handling commands, which are
71  * sent to one of the channels.
72  *
73  * @author Jonas Fleck - Initial contribution
74  * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
75  */
76 @NonNullByDefault
77 public class BoschIndegoHandler extends BaseThingHandler implements AuthorizationListener {
78
79     private static final String MAP_POSITION_STROKE_COLOR = "#8c8b6d";
80     private static final String MAP_POSITION_FILL_COLOR = "#fff701";
81     private static final int MAP_POSITION_RADIUS = 10;
82     private static final Duration DEVICE_PROPERTIES_VALIDITY_PERIOD = Duration.ofDays(1);
83
84     private static final Duration MAP_REFRESH_INTERVAL = Duration.ofDays(1);
85     private static final Duration OPERATING_DATA_INACTIVE_REFRESH_INTERVAL = Duration.ofHours(6);
86     private static final Duration OPERATING_DATA_OFFLINE_REFRESH_INTERVAL = Duration.ofMinutes(30);
87     private static final Duration OPERATING_DATA_ACTIVE_REFRESH_INTERVAL = Duration.ofMinutes(2);
88     private static final Duration MAP_REFRESH_SESSION_DURATION = Duration.ofMinutes(5);
89     private static final Duration COMMAND_STATE_REFRESH_TIMEOUT = Duration.ofSeconds(10);
90
91     private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
92     private final HttpClient httpClient;
93     private final BoschIndegoTranslationProvider translationProvider;
94     private final TimeZoneProvider timeZoneProvider;
95     private Instant devicePropertiesUpdated = Instant.MIN;
96
97     private @NonNullByDefault({}) AuthorizationProvider authorizationProvider;
98     private @NonNullByDefault({}) IndegoDeviceController controller;
99     private @Nullable ScheduledFuture<?> statePollFuture;
100     private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
101     private @Nullable ScheduledFuture<?> cuttingTimeFuture;
102     private Optional<Integer> previousStateCode = Optional.empty();
103     private @Nullable RawType cachedMap;
104     private Instant cachedMapTimestamp = Instant.MIN;
105     private Instant operatingDataTimestamp = Instant.MIN;
106     private Instant mapRefreshStartedTimestamp = Instant.MIN;
107     private ThingStatus lastOperatingDataStatus = ThingStatus.UNINITIALIZED;
108     private int stateInactiveRefreshIntervalSeconds;
109     private int stateActiveRefreshIntervalSeconds;
110     private int currentRefreshIntervalSeconds;
111
112     public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
113             TimeZoneProvider timeZoneProvider) {
114         super(thing);
115         this.httpClient = httpClient;
116         this.translationProvider = translationProvider;
117         this.timeZoneProvider = timeZoneProvider;
118     }
119
120     @Override
121     public void initialize() {
122         BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
123         stateInactiveRefreshIntervalSeconds = (int) config.refresh;
124         stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh;
125
126         Bridge bridge = getBridge();
127         if (bridge == null) {
128             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
129                     "@text/offline.conf-error.missing-bridge");
130             return;
131         }
132
133         if (bridge.getHandler() instanceof BoschAccountHandler accountHandler) {
134             authorizationProvider = accountHandler.getAuthorizationProvider();
135             accountHandler.registerAuthorizationListener(this);
136         } else {
137             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
138                     "@text/offline.conf-error.missing-bridge");
139             return;
140         }
141
142         devicePropertiesUpdated = Instant.MIN;
143         updateProperty(Thing.PROPERTY_SERIAL_NUMBER, config.serialNumber);
144
145         controller = new IndegoDeviceController(httpClient, authorizationProvider, config.serialNumber);
146
147         updateStatus(ThingStatus.UNKNOWN);
148         previousStateCode = Optional.empty();
149         rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, false);
150         this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
151                 config.cuttingTimeRefresh, TimeUnit.MINUTES);
152     }
153
154     @Override
155     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
156         if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE
157                 && getThing().getStatusInfo().getStatus() == ThingStatus.OFFLINE) {
158             updateStatus(ThingStatus.UNKNOWN);
159         } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
160             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
161         }
162     }
163
164     @Override
165     public void onSuccessfulAuthorization() {
166         // Ignore
167     }
168
169     @Override
170     public void onFailedAuthorization(Throwable throwable) {
171         // Ignore
172     }
173
174     @Override
175     public void onAuthorizationFlowCompleted() {
176         // Trigger immediate state refresh upon authorization success.
177         rescheduleStatePoll(0, stateInactiveRefreshIntervalSeconds, true);
178     }
179
180     private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds, boolean force) {
181         ScheduledFuture<?> statePollFuture = this.statePollFuture;
182         if (statePollFuture != null) {
183             if (!force && refreshIntervalSeconds == currentRefreshIntervalSeconds) {
184                 // No change.
185                 return false;
186             }
187             statePollFuture.cancel(force);
188         }
189         logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
190                 delaySeconds);
191         this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateWithExceptionHandling, delaySeconds,
192                 refreshIntervalSeconds, TimeUnit.SECONDS);
193         currentRefreshIntervalSeconds = refreshIntervalSeconds;
194
195         return true;
196     }
197
198     @Override
199     public void dispose() {
200         Bridge bridge = getBridge();
201         if (bridge != null) {
202             if (bridge.getHandler() instanceof BoschAccountHandler accountHandler) {
203                 accountHandler.unregisterAuthorizationListener(this);
204             }
205         }
206
207         ScheduledFuture<?> pollFuture = this.statePollFuture;
208         if (pollFuture != null) {
209             pollFuture.cancel(true);
210         }
211         this.statePollFuture = null;
212         pollFuture = this.cuttingTimePollFuture;
213         if (pollFuture != null) {
214             pollFuture.cancel(true);
215         }
216         this.cuttingTimePollFuture = null;
217         pollFuture = this.cuttingTimeFuture;
218         if (pollFuture != null) {
219             pollFuture.cancel(true);
220         }
221         this.cuttingTimeFuture = null;
222     }
223
224     @Override
225     public void handleCommand(ChannelUID channelUID, Command command) {
226         logger.debug("handleCommand {} for channel {}", command, channelUID);
227         try {
228             if (command == RefreshType.REFRESH) {
229                 handleRefreshCommand(channelUID.getId());
230                 return;
231             }
232             if (command instanceof DecimalType decimalCommand && channelUID.getId().equals(STATE)) {
233                 sendCommand(decimalCommand.intValue());
234             }
235         } catch (IndegoAuthenticationException e) {
236             // Ignore, will be handled by bridge
237         } catch (IndegoTimeoutException e) {
238             updateStatus(lastOperatingDataStatus = ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
239                     "@text/offline.comm-error.unreachable");
240         } catch (IndegoInvalidCommandException e) {
241             logger.warn("Invalid command: {}", e.getMessage());
242             if (e.hasErrorCode()) {
243                 updateState(ERRORCODE, new DecimalType(e.getErrorCode()));
244             }
245         } catch (IndegoException e) {
246             logger.warn("Command failed: {}", e.getMessage());
247         }
248     }
249
250     private void handleRefreshCommand(String channelId)
251             throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
252         switch (channelId) {
253             case GARDEN_MAP:
254                 // Force map refresh and fall through to state update.
255                 cachedMapTimestamp = Instant.MIN;
256             case STATE:
257             case TEXTUAL_STATE:
258             case MOWED:
259             case ERRORCODE:
260             case STATECODE:
261             case READY:
262                 refreshState();
263                 break;
264             case LAST_CUTTING:
265                 refreshLastCuttingTime();
266                 break;
267             case NEXT_CUTTING:
268                 refreshNextCuttingTime();
269                 break;
270             case BATTERY_LEVEL:
271             case LOW_BATTERY:
272             case BATTERY_VOLTAGE:
273             case BATTERY_TEMPERATURE:
274             case GARDEN_SIZE:
275                 refreshOperatingData();
276                 break;
277         }
278     }
279
280     private void sendCommand(int commandInt) throws IndegoException {
281         DeviceCommand command;
282         switch (commandInt) {
283             case 1:
284                 command = DeviceCommand.MOW;
285                 break;
286             case 2:
287                 command = DeviceCommand.RETURN;
288                 break;
289             case 3:
290                 command = DeviceCommand.PAUSE;
291                 break;
292             default:
293                 logger.warn("Invalid command {}", commandInt);
294                 return;
295         }
296
297         DeviceStateResponse state = controller.getState();
298         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
299         if (!verifyCommand(command, deviceStatus, state.error)) {
300             return;
301         }
302         logger.debug("Sending command {}", command);
303         controller.sendCommand(command);
304
305         // State is not updated immediately, so await new state for some seconds.
306         // For command MOW, state will shortly be updated to 262 (docked, loading map).
307         // This is considered "active", so after this state change, polling frequency will
308         // be increased for faster updates.
309         DeviceStateResponse stateResponse = controller.getState(COMMAND_STATE_REFRESH_TIMEOUT);
310         if (stateResponse.state != 0) {
311             updateState(stateResponse);
312             deviceStatus = DeviceStatus.fromCode(stateResponse.state);
313             rescheduleStatePollAccordingToState(deviceStatus);
314         }
315     }
316
317     private void refreshStateWithExceptionHandling() {
318         try {
319             refreshState();
320         } catch (IndegoAuthenticationException e) {
321             // Ignore, will be handled by bridge
322         } catch (IndegoTimeoutException e) {
323             updateStatus(lastOperatingDataStatus = ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
324                     "@text/offline.comm-error.unreachable");
325         } catch (IndegoException e) {
326             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
327         }
328     }
329
330     private void refreshState() throws IndegoAuthenticationException, IndegoException {
331         DeviceStateResponse state = controller.getState();
332         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
333         updateState(state);
334
335         if (devicePropertiesUpdated.isBefore(Instant.now().minus(DEVICE_PROPERTIES_VALIDITY_PERIOD))) {
336             refreshDeviceProperties();
337         }
338
339         // Update map and start tracking positions if mower is active.
340         if (state.mapUpdateAvailable) {
341             cachedMapTimestamp = Instant.MIN;
342         }
343         refreshMap(state.svgXPos, state.svgYPos);
344         if (deviceStatus.isActive()) {
345             trackPosition();
346         }
347
348         int previousState;
349         DeviceStatus previousDeviceStatus;
350         if (previousStateCode.isPresent()) {
351             previousState = previousStateCode.get();
352             previousDeviceStatus = DeviceStatus.fromCode(previousState);
353             if (state.state != previousState
354                     && ((!previousDeviceStatus.isDocked() && deviceStatus.isDocked()) || deviceStatus.isCompleted())) {
355                 // When returning to dock or on its way after completing lawn, refresh last cutting time immediately.
356                 // We cannot fully rely on completed lawn state since active polling refresh interval is configurable
357                 // and we might miss the state if mower returns before next poll.
358                 refreshLastCuttingTime();
359             }
360         } else {
361             previousState = state.state;
362             previousDeviceStatus = DeviceStatus.fromCode(previousState);
363         }
364         previousStateCode = Optional.of(state.state);
365
366         refreshOperatingDataConditionally(
367                 previousDeviceStatus.isCharging() || deviceStatus.isCharging() || deviceStatus.isActive());
368
369         if (lastOperatingDataStatus == ThingStatus.ONLINE && thing.getStatus() != ThingStatus.ONLINE) {
370             // Revert temporary offline status caused by disruptions other than unreachable device.
371             updateStatus(ThingStatus.ONLINE);
372         } else if (lastOperatingDataStatus == ThingStatus.OFFLINE) {
373             // Update description to reflect why thing is still offline.
374             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
375                     "@text/offline.comm-error.unreachable");
376         }
377
378         rescheduleStatePollAccordingToState(deviceStatus);
379     }
380
381     private void refreshDeviceProperties() throws IndegoAuthenticationException, IndegoException {
382         DevicePropertiesResponse deviceProperties = controller.getDeviceProperties();
383         Map<String, String> properties = editProperties();
384         if (deviceProperties.firmwareVersion != null) {
385             properties.put(Thing.PROPERTY_FIRMWARE_VERSION, deviceProperties.firmwareVersion);
386         }
387         if (deviceProperties.bareToolNumber != null) {
388             properties.put(Thing.PROPERTY_MODEL_ID,
389                     IndegoTypeDatabase.nameFromTypeNumber(deviceProperties.bareToolNumber));
390             properties.put(PROPERTY_BARE_TOOL_NUMBER, deviceProperties.bareToolNumber);
391         }
392         properties.put(PROPERTY_SERVICE_COUNTER, String.valueOf(deviceProperties.serviceCounter));
393         properties.put(PROPERTY_NEEDS_SERVICE, String.valueOf(deviceProperties.needsService));
394         properties.put(PROPERTY_RENEW_DATE,
395                 LocalDateTime.ofInstant(deviceProperties.renewDate, timeZoneProvider.getTimeZone()).toString());
396
397         updateProperties(properties);
398         devicePropertiesUpdated = Instant.now();
399     }
400
401     private void rescheduleStatePollAccordingToState(DeviceStatus deviceStatus) {
402         int refreshIntervalSeconds;
403         if (deviceStatus.isActive()) {
404             refreshIntervalSeconds = stateActiveRefreshIntervalSeconds;
405         } else if (deviceStatus.isCharging()) {
406             refreshIntervalSeconds = (int) OPERATING_DATA_ACTIVE_REFRESH_INTERVAL.getSeconds();
407         } else {
408             refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
409         }
410         if (rescheduleStatePoll(refreshIntervalSeconds, refreshIntervalSeconds, false)) {
411             // After job has been rescheduled, request operating data one last time on next poll.
412             // This is needed to update battery values after a charging cycle has completed.
413             operatingDataTimestamp = Instant.MIN;
414         }
415     }
416
417     private void refreshOperatingDataConditionally(boolean isActive)
418             throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
419         // Refresh operating data only occationally or when robot is active/charging.
420         // This will contact the robot directly through cellular network and wake it up
421         // when sleeping. Additionally, refresh more often after being offline to try to get
422         // back online as soon as possible without putting too much stress on the service.
423         if ((isActive && operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_ACTIVE_REFRESH_INTERVAL)))
424                 || (lastOperatingDataStatus != ThingStatus.ONLINE && operatingDataTimestamp
425                         .isBefore(Instant.now().minus(OPERATING_DATA_OFFLINE_REFRESH_INTERVAL)))
426                 || operatingDataTimestamp.isBefore(Instant.now().minus(OPERATING_DATA_INACTIVE_REFRESH_INTERVAL))) {
427             refreshOperatingData();
428         }
429     }
430
431     private void refreshOperatingData() throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
432         updateOperatingData(controller.getOperatingData());
433         operatingDataTimestamp = Instant.now();
434         updateStatus(lastOperatingDataStatus = ThingStatus.ONLINE);
435     }
436
437     private void refreshCuttingTimesWithExceptionHandling() {
438         try {
439             refreshLastCuttingTime();
440             refreshNextCuttingTime();
441         } catch (IndegoAuthenticationException e) {
442             // Ignore, will be handled by bridge
443         } catch (IndegoException e) {
444             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
445         }
446     }
447
448     private void refreshLastCuttingTime() throws IndegoAuthenticationException, IndegoException {
449         if (isLinked(LAST_CUTTING)) {
450             Instant lastCutting = controller.getPredictiveLastCutting();
451             if (lastCutting != null) {
452                 updateState(LAST_CUTTING,
453                         new DateTimeType(ZonedDateTime.ofInstant(lastCutting, timeZoneProvider.getTimeZone())));
454             } else {
455                 updateState(LAST_CUTTING, UnDefType.UNDEF);
456             }
457         }
458     }
459
460     private void refreshNextCuttingTimeWithExceptionHandling() {
461         try {
462             refreshNextCuttingTime();
463         } catch (IndegoAuthenticationException e) {
464             // Ignore, will be handled by bridge
465         } catch (IndegoException e) {
466             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
467         }
468     }
469
470     private void refreshNextCuttingTime() throws IndegoAuthenticationException, IndegoException {
471         cancelCuttingTimeRefresh();
472         if (isLinked(NEXT_CUTTING)) {
473             Instant nextCutting = controller.getPredictiveNextCutting();
474             if (nextCutting != null) {
475                 updateState(NEXT_CUTTING,
476                         new DateTimeType(ZonedDateTime.ofInstant(nextCutting, timeZoneProvider.getTimeZone())));
477                 scheduleCuttingTimesRefresh(nextCutting);
478             } else {
479                 updateState(NEXT_CUTTING, UnDefType.UNDEF);
480             }
481         }
482     }
483
484     private void cancelCuttingTimeRefresh() {
485         ScheduledFuture<?> cuttingTimeFuture = this.cuttingTimeFuture;
486         if (cuttingTimeFuture != null) {
487             // Do not interrupt as we might be running within that job.
488             cuttingTimeFuture.cancel(false);
489             this.cuttingTimeFuture = null;
490         }
491     }
492
493     private void scheduleCuttingTimesRefresh(Instant nextCutting) {
494         // Schedule additional update right after next planned cutting. This ensures a faster update.
495         long secondsUntilNextCutting = Instant.now().until(nextCutting, ChronoUnit.SECONDS) + 2;
496         if (secondsUntilNextCutting > 0) {
497             logger.debug("Scheduling fetching of next cutting time in {} seconds", secondsUntilNextCutting);
498             this.cuttingTimeFuture = scheduler.schedule(this::refreshNextCuttingTimeWithExceptionHandling,
499                     secondsUntilNextCutting, TimeUnit.SECONDS);
500         }
501     }
502
503     private void refreshMap(int xPos, int yPos) throws IndegoAuthenticationException, IndegoException {
504         if (!isLinked(GARDEN_MAP)) {
505             return;
506         }
507         RawType cachedMap = this.cachedMap;
508         boolean mapRefreshed;
509         if (cachedMap == null || cachedMapTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_INTERVAL))) {
510             this.cachedMap = cachedMap = controller.getMap();
511             cachedMapTimestamp = Instant.now();
512             mapRefreshed = true;
513         } else {
514             mapRefreshed = false;
515         }
516         String svgMap = new String(cachedMap.getBytes(), StandardCharsets.UTF_8);
517         if (!svgMap.endsWith("</svg>")) {
518             if (mapRefreshed) {
519                 logger.warn("Unexpected map format, unable to plot location");
520                 logger.trace("Received map: {}", svgMap);
521                 updateState(GARDEN_MAP, cachedMap);
522             }
523             return;
524         }
525         svgMap = svgMap.substring(0, svgMap.length() - 6) + "<circle cx=\"" + xPos + "\" cy=\"" + yPos + "\" r=\""
526                 + MAP_POSITION_RADIUS + "\" stroke=\"" + MAP_POSITION_STROKE_COLOR + "\" fill=\""
527                 + MAP_POSITION_FILL_COLOR + "\" />\n</svg>";
528         updateState(GARDEN_MAP, new RawType(svgMap.getBytes(), cachedMap.getMimeType()));
529     }
530
531     private void trackPosition() throws IndegoAuthenticationException, IndegoException {
532         if (!isLinked(GARDEN_MAP)) {
533             return;
534         }
535         if (mapRefreshStartedTimestamp.isBefore(Instant.now().minus(MAP_REFRESH_SESSION_DURATION))) {
536             int count = (int) MAP_REFRESH_SESSION_DURATION.getSeconds() / stateActiveRefreshIntervalSeconds + 1;
537             logger.debug("Requesting position updates (count: {}; interval: {}s), previously triggered {}", count,
538                     stateActiveRefreshIntervalSeconds, mapRefreshStartedTimestamp);
539             controller.requestPosition(count, stateActiveRefreshIntervalSeconds);
540             mapRefreshStartedTimestamp = Instant.now();
541         }
542     }
543
544     private void updateState(DeviceStateResponse state) {
545         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
546         DeviceCommand associatedCommand = deviceStatus.getAssociatedCommand();
547         int status = associatedCommand != null ? getStatusFromCommand(associatedCommand) : 0;
548         int mowed = state.mowed;
549         int error = state.error;
550         int statecode = state.state;
551         boolean ready = isReadyToMow(deviceStatus, state.error);
552
553         updateState(STATECODE, new DecimalType(statecode));
554         updateState(READY, new DecimalType(ready ? 1 : 0));
555         updateState(ERRORCODE, new DecimalType(error));
556         updateState(MOWED, new PercentType(mowed));
557         updateState(STATE, new DecimalType(status));
558         updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage(translationProvider)));
559     }
560
561     private void updateOperatingData(OperatingDataResponse operatingData) {
562         updateState(BATTERY_VOLTAGE, new QuantityType<>(operatingData.battery.voltage, Units.VOLT));
563         updateState(BATTERY_LEVEL, new DecimalType(operatingData.battery.percent));
564         updateState(LOW_BATTERY, OnOffType.from(operatingData.battery.percent < 20));
565         updateState(BATTERY_TEMPERATURE, new QuantityType<>(operatingData.battery.batteryTemperature, SIUnits.CELSIUS));
566         updateState(GARDEN_SIZE, new QuantityType<>(operatingData.garden.size, SIUnits.SQUARE_METRE));
567     }
568
569     private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
570         return deviceStatus.isReadyToMow() && error == 0;
571     }
572
573     private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
574         // Mower reported an error
575         if (errorCode != 0) {
576             logger.warn("The mower reported an error.");
577             return false;
578         }
579
580         // Command is equal to current state
581         if (command == deviceStatus.getAssociatedCommand()) {
582             logger.debug("Command is equal to state");
583             return false;
584         }
585         // Can't pause while the mower is docked
586         if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
587             logger.info("Can't pause the mower while it's docked or docking");
588             return false;
589         }
590         // Command means "MOW" but mower is not ready
591         if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
592             logger.info("The mower is not ready to mow at the moment");
593             return false;
594         }
595         return true;
596     }
597
598     private int getStatusFromCommand(DeviceCommand command) {
599         switch (command) {
600             case MOW:
601                 return 1;
602             case RETURN:
603                 return 2;
604             case PAUSE:
605                 return 3;
606             default:
607                 return 0;
608         }
609     }
610 }