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