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