]> git.basschouten.com Git - openhab-addons.git/blob
f57798bbca76f83af3926bef0a131b581db58939
[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.ZonedDateTime;
21 import java.time.temporal.ChronoUnit;
22 import java.util.Optional;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
25
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;
64
65 /**
66  * The {@link BoschIndegoHandler} is responsible for handling commands, which are
67  * sent to one of the channels.
68  *
69  * @author Jonas Fleck - Initial contribution
70  * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
71  */
72 @NonNullByDefault
73 public class BoschIndegoHandler extends BaseThingHandler {
74
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;
78
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);
85
86     private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
87     private final HttpClient httpClient;
88     private final BoschIndegoTranslationProvider translationProvider;
89     private final TimeZoneProvider timeZoneProvider;
90
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;
105
106     public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
107             TimeZoneProvider timeZoneProvider) {
108         super(thing);
109         this.httpClient = httpClient;
110         this.translationProvider = translationProvider;
111         this.timeZoneProvider = timeZoneProvider;
112     }
113
114     @Override
115     public void initialize() {
116         BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
117         stateInactiveRefreshIntervalSeconds = (int) config.refresh;
118         stateActiveRefreshIntervalSeconds = (int) config.stateActiveRefresh;
119
120         Bridge bridge = getBridge();
121         if (bridge == null) {
122             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
123                     "@text/offline.conf-error.missing-bridge");
124             return;
125         }
126
127         ThingHandler handler = bridge.getHandler();
128         if (handler instanceof BoschAccountHandler accountHandler) {
129             this.oAuthClientService = accountHandler.getOAuthClientService();
130         } else {
131             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
132                     "@text/offline.conf-error.missing-bridge");
133             return;
134         }
135
136         this.updateProperty(Thing.PROPERTY_SERIAL_NUMBER, config.serialNumber);
137
138         controller = new IndegoDeviceController(httpClient, oAuthClientService, config.serialNumber);
139
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);
145     }
146
147     @Override
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);
155         }
156     }
157
158     private boolean rescheduleStatePoll(int delaySeconds, int refreshIntervalSeconds, boolean force) {
159         ScheduledFuture<?> statePollFuture = this.statePollFuture;
160         if (statePollFuture != null) {
161             if (!force && refreshIntervalSeconds == currentRefreshIntervalSeconds) {
162                 // No change.
163                 return false;
164             }
165             statePollFuture.cancel(force);
166         }
167         logger.debug("Scheduling state refresh job with {}s interval and {}s delay", refreshIntervalSeconds,
168                 delaySeconds);
169         this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateWithExceptionHandling, delaySeconds,
170                 refreshIntervalSeconds, TimeUnit.SECONDS);
171         currentRefreshIntervalSeconds = refreshIntervalSeconds;
172
173         return true;
174     }
175
176     @Override
177     public void dispose() {
178         ScheduledFuture<?> pollFuture = this.statePollFuture;
179         if (pollFuture != null) {
180             pollFuture.cancel(true);
181         }
182         this.statePollFuture = null;
183         pollFuture = this.cuttingTimePollFuture;
184         if (pollFuture != null) {
185             pollFuture.cancel(true);
186         }
187         this.cuttingTimePollFuture = null;
188         pollFuture = this.cuttingTimeFuture;
189         if (pollFuture != null) {
190             pollFuture.cancel(true);
191         }
192         this.cuttingTimeFuture = null;
193     }
194
195     @Override
196     public void handleCommand(ChannelUID channelUID, Command command) {
197         logger.debug("handleCommand {} for channel {}", command, channelUID);
198         try {
199             if (command == RefreshType.REFRESH) {
200                 handleRefreshCommand(channelUID.getId());
201                 return;
202             }
203             if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
204                 sendCommand(((DecimalType) command).intValue());
205             }
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()));
216             }
217         } catch (IndegoException e) {
218             logger.warn("Command failed: {}", e.getMessage());
219         }
220     }
221
222     private void handleRefreshCommand(String channelId)
223             throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
224         switch (channelId) {
225             case GARDEN_MAP:
226                 // Force map refresh and fall through to state update.
227                 cachedMapTimestamp = Instant.MIN;
228             case STATE:
229             case TEXTUAL_STATE:
230             case MOWED:
231             case ERRORCODE:
232             case STATECODE:
233             case READY:
234                 refreshState();
235                 break;
236             case LAST_CUTTING:
237                 refreshLastCuttingTime();
238                 break;
239             case NEXT_CUTTING:
240                 refreshNextCuttingTime();
241                 break;
242             case BATTERY_LEVEL:
243             case LOW_BATTERY:
244             case BATTERY_VOLTAGE:
245             case BATTERY_TEMPERATURE:
246             case GARDEN_SIZE:
247                 refreshOperatingData();
248                 break;
249         }
250     }
251
252     private void sendCommand(int commandInt) throws IndegoException {
253         DeviceCommand command;
254         switch (commandInt) {
255             case 1:
256                 command = DeviceCommand.MOW;
257                 break;
258             case 2:
259                 command = DeviceCommand.RETURN;
260                 break;
261             case 3:
262                 command = DeviceCommand.PAUSE;
263                 break;
264             default:
265                 logger.warn("Invalid command {}", commandInt);
266                 return;
267         }
268
269         DeviceStateResponse state = controller.getState();
270         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
271         if (!verifyCommand(command, deviceStatus, state.error)) {
272             return;
273         }
274         logger.debug("Sending command {}", command);
275         controller.sendCommand(command);
276
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);
286         }
287     }
288
289     private void refreshStateWithExceptionHandling() {
290         try {
291             refreshState();
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());
301         }
302     }
303
304     private void refreshState() throws IndegoAuthenticationException, IndegoException {
305         DeviceStateResponse state = controller.getState();
306         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
307         updateState(state);
308
309         // Update map and start tracking positions if mower is active.
310         if (state.mapUpdateAvailable) {
311             cachedMapTimestamp = Instant.MIN;
312         }
313         refreshMap(state.svgXPos, state.svgYPos);
314         if (deviceStatus.isActive()) {
315             trackPosition();
316         }
317
318         int previousState;
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();
329             }
330         } else {
331             previousState = state.state;
332             previousDeviceStatus = DeviceStatus.fromCode(previousState);
333         }
334         previousStateCode = Optional.of(state.state);
335
336         refreshOperatingDataConditionally(
337                 previousDeviceStatus.isCharging() || deviceStatus.isCharging() || deviceStatus.isActive());
338
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");
346         }
347
348         rescheduleStatePollAccordingToState(deviceStatus);
349     }
350
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();
357         } else {
358             refreshIntervalSeconds = stateInactiveRefreshIntervalSeconds;
359         }
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;
364         }
365     }
366
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();
378         }
379     }
380
381     private void refreshOperatingData() throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
382         updateOperatingData(controller.getOperatingData());
383         operatingDataTimestamp = Instant.now();
384         updateStatus(lastOperatingDataStatus = ThingStatus.ONLINE);
385     }
386
387     private void refreshCuttingTimesWithExceptionHandling() {
388         try {
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());
396         }
397     }
398
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())));
405             } else {
406                 updateState(LAST_CUTTING, UnDefType.UNDEF);
407             }
408         }
409     }
410
411     private void refreshNextCuttingTimeWithExceptionHandling() {
412         try {
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());
419         }
420     }
421
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);
430             } else {
431                 updateState(NEXT_CUTTING, UnDefType.UNDEF);
432             }
433         }
434     }
435
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;
442         }
443     }
444
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);
452         }
453     }
454
455     private void refreshMap(int xPos, int yPos) throws IndegoAuthenticationException, IndegoException {
456         if (!isLinked(GARDEN_MAP)) {
457             return;
458         }
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();
464             mapRefreshed = true;
465         } else {
466             mapRefreshed = false;
467         }
468         String svgMap = new String(cachedMap.getBytes(), StandardCharsets.UTF_8);
469         if (!svgMap.endsWith("</svg>")) {
470             if (mapRefreshed) {
471                 logger.warn("Unexpected map format, unable to plot location");
472                 logger.trace("Received map: {}", svgMap);
473                 updateState(GARDEN_MAP, cachedMap);
474             }
475             return;
476         }
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()));
481     }
482
483     private void trackPosition() throws IndegoAuthenticationException, IndegoException {
484         if (!isLinked(GARDEN_MAP)) {
485             return;
486         }
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();
493         }
494     }
495
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);
504
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)));
511     }
512
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));
519     }
520
521     private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
522         return deviceStatus.isReadyToMow() && error == 0;
523     }
524
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.");
529             return false;
530         }
531
532         // Command is equal to current state
533         if (command == deviceStatus.getAssociatedCommand()) {
534             logger.debug("Command is equal to state");
535             return false;
536         }
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");
540             return false;
541         }
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");
545             return false;
546         }
547         return true;
548     }
549
550     private int getStatusFromCommand(DeviceCommand command) {
551         switch (command) {
552             case MOW:
553                 return 1;
554             case RETURN:
555                 return 2;
556             case PAUSE:
557                 return 3;
558             default:
559                 return 0;
560         }
561     }
562 }