]> git.basschouten.com Git - openhab-addons.git/blob
3500ac0477bf93bc1096d5623d89d8100887dc12
[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     private static final int MAP_REFRESH_INTERVAL_DAYS = 1;
74
75     private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
76     private final HttpClient httpClient;
77     private final BoschIndegoTranslationProvider translationProvider;
78     private final TimeZoneProvider timeZoneProvider;
79
80     private @NonNullByDefault({}) IndegoController controller;
81     private @Nullable ScheduledFuture<?> statePollFuture;
82     private @Nullable ScheduledFuture<?> cuttingTimePollFuture;
83     private @Nullable ScheduledFuture<?> cuttingTimeFuture;
84     private boolean propertiesInitialized;
85     private Optional<Integer> previousStateCode = Optional.empty();
86     private @Nullable RawType cachedMap;
87     private Instant cachedMapTimestamp = Instant.MIN;
88
89     public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
90             TimeZoneProvider timeZoneProvider) {
91         super(thing);
92         this.httpClient = httpClient;
93         this.translationProvider = translationProvider;
94         this.timeZoneProvider = timeZoneProvider;
95     }
96
97     @Override
98     public void initialize() {
99         logger.debug("Initializing Indego handler");
100         BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
101         String username = config.username;
102         String password = config.password;
103
104         if (username == null || username.isBlank()) {
105             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
106                     "@text/offline.conf-error.missing-username");
107             return;
108         }
109         if (password == null || password.isBlank()) {
110             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
111                     "@text/offline.conf-error.missing-password");
112             return;
113         }
114
115         controller = new IndegoController(httpClient, username, password);
116
117         updateStatus(ThingStatus.UNKNOWN);
118         previousStateCode = Optional.empty();
119         this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateAndOperatingDataWithExceptionHandling,
120                 0, config.refresh, TimeUnit.SECONDS);
121         this.cuttingTimePollFuture = scheduler.scheduleWithFixedDelay(this::refreshCuttingTimesWithExceptionHandling, 0,
122                 config.cuttingTimeRefresh, TimeUnit.MINUTES);
123     }
124
125     @Override
126     public void dispose() {
127         logger.debug("Disposing Indego handler");
128         ScheduledFuture<?> pollFuture = this.statePollFuture;
129         if (pollFuture != null) {
130             pollFuture.cancel(true);
131         }
132         this.statePollFuture = null;
133         pollFuture = this.cuttingTimePollFuture;
134         if (pollFuture != null) {
135             pollFuture.cancel(true);
136         }
137         this.cuttingTimePollFuture = null;
138         pollFuture = this.cuttingTimeFuture;
139         if (pollFuture != null) {
140             pollFuture.cancel(true);
141         }
142         this.cuttingTimeFuture = null;
143
144         scheduler.execute(() -> {
145             try {
146                 controller.deauthenticate();
147             } catch (IndegoException e) {
148                 logger.debug("Deauthentication failed", e);
149             }
150         });
151     }
152
153     @Override
154     public void handleCommand(ChannelUID channelUID, Command command) {
155         logger.debug("handleCommand {} for channel {}", command, channelUID);
156         try {
157             if (command == RefreshType.REFRESH) {
158                 handleRefreshCommand(channelUID.getId());
159                 return;
160             }
161             if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
162                 sendCommand(((DecimalType) command).intValue());
163             }
164         } catch (IndegoAuthenticationException e) {
165             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
166                     "@text/offline.comm-error.authentication-failure");
167         } catch (IndegoUnreachableException e) {
168             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
169                     "@text/offline.comm-error.unreachable");
170         } catch (IndegoException e) {
171             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
172         }
173     }
174
175     private void handleRefreshCommand(String channelId)
176             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
177         switch (channelId) {
178             case GARDEN_MAP:
179                 // Force map refresh and fall through to state update.
180                 cachedMapTimestamp = Instant.MIN;
181             case STATE:
182             case TEXTUAL_STATE:
183             case MOWED:
184             case ERRORCODE:
185             case STATECODE:
186             case READY:
187                 refreshState();
188                 break;
189             case LAST_CUTTING:
190             case NEXT_CUTTING:
191                 refreshCuttingTimes();
192                 break;
193             case BATTERY_LEVEL:
194             case LOW_BATTERY:
195             case BATTERY_VOLTAGE:
196             case BATTERY_TEMPERATURE:
197             case GARDEN_SIZE:
198                 refreshOperatingData();
199                 break;
200         }
201     }
202
203     private void sendCommand(int commandInt) throws IndegoException {
204         DeviceCommand command;
205         switch (commandInt) {
206             case 1:
207                 command = DeviceCommand.MOW;
208                 break;
209             case 2:
210                 command = DeviceCommand.RETURN;
211                 break;
212             case 3:
213                 command = DeviceCommand.PAUSE;
214                 break;
215             default:
216                 logger.warn("Invalid command {}", commandInt);
217                 return;
218         }
219
220         DeviceStateResponse state = controller.getState();
221         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
222         if (!verifyCommand(command, deviceStatus, state.error)) {
223             return;
224         }
225         logger.debug("Sending command {}", command);
226         updateState(TEXTUAL_STATE, UnDefType.UNDEF);
227         controller.sendCommand(command);
228         refreshState();
229     }
230
231     private void refreshStateAndOperatingDataWithExceptionHandling() {
232         try {
233             refreshState();
234             refreshOperatingData();
235         } catch (IndegoAuthenticationException e) {
236             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
237                     "@text/offline.comm-error.authentication-failure");
238         } catch (IndegoUnreachableException e) {
239             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
240                     "@text/offline.comm-error.unreachable");
241         } catch (IndegoException e) {
242             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
243         }
244     }
245
246     private void refreshState() throws IndegoAuthenticationException, IndegoException {
247         if (!propertiesInitialized) {
248             getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
249             propertiesInitialized = true;
250         }
251
252         DeviceStateResponse state = controller.getState();
253         updateState(state);
254
255         if (state.mapUpdateAvailable) {
256             cachedMapTimestamp = Instant.MIN;
257         }
258         refreshMap(state.svgXPos, state.svgYPos);
259
260         // When state code changed, refresh cutting times immediately.
261         if (previousStateCode.isPresent() && state.state != previousStateCode.get()) {
262             refreshCuttingTimes();
263
264             // After learning lawn, trigger a forced map refresh on next poll.
265             if (previousStateCode.get() == DeviceStatus.STATE_LEARNING_LAWN) {
266                 cachedMapTimestamp = Instant.MIN;
267             }
268         }
269         previousStateCode = Optional.of(state.state);
270     }
271
272     private void refreshOperatingData()
273             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
274         updateOperatingData(controller.getOperatingData());
275         updateStatus(ThingStatus.ONLINE);
276     }
277
278     private void refreshCuttingTimesWithExceptionHandling() {
279         try {
280             refreshCuttingTimes();
281         } catch (IndegoAuthenticationException e) {
282             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
283                     "@text/offline.comm-error.authentication-failure");
284         } catch (IndegoException e) {
285             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
286         }
287     }
288
289     private void refreshCuttingTimes() throws IndegoAuthenticationException, IndegoException {
290         if (isLinked(LAST_CUTTING)) {
291             Instant lastCutting = controller.getPredictiveLastCutting();
292             if (lastCutting != null) {
293                 updateState(LAST_CUTTING,
294                         new DateTimeType(ZonedDateTime.ofInstant(lastCutting, timeZoneProvider.getTimeZone())));
295             } else {
296                 updateState(LAST_CUTTING, UnDefType.UNDEF);
297             }
298         }
299
300         cancelCuttingTimeRefresh();
301         if (isLinked(NEXT_CUTTING)) {
302             Instant nextCutting = controller.getPredictiveNextCutting();
303             if (nextCutting != null) {
304                 updateState(NEXT_CUTTING,
305                         new DateTimeType(ZonedDateTime.ofInstant(nextCutting, timeZoneProvider.getTimeZone())));
306                 scheduleCuttingTimesRefresh(nextCutting);
307             } else {
308                 updateState(NEXT_CUTTING, UnDefType.UNDEF);
309             }
310         }
311     }
312
313     private void cancelCuttingTimeRefresh() {
314         ScheduledFuture<?> cuttingTimeFuture = this.cuttingTimeFuture;
315         if (cuttingTimeFuture != null) {
316             // Do not interrupt as we might be running within that job.
317             cuttingTimeFuture.cancel(false);
318             this.cuttingTimeFuture = null;
319         }
320     }
321
322     private void scheduleCuttingTimesRefresh(Instant nextCutting) {
323         // Schedule additional update right after next planned cutting. This ensures a faster update
324         // in case the next cutting will be postponed (for example due to weather conditions).
325         long secondsUntilNextCutting = Instant.now().until(nextCutting, ChronoUnit.SECONDS) + 2;
326         if (secondsUntilNextCutting > 0) {
327             logger.debug("Scheduling fetching of cutting times in {} seconds", secondsUntilNextCutting);
328             this.cuttingTimeFuture = scheduler.schedule(this::refreshCuttingTimesWithExceptionHandling,
329                     secondsUntilNextCutting, TimeUnit.SECONDS);
330         }
331     }
332
333     private void refreshMap(int xPos, int yPos) throws IndegoAuthenticationException, IndegoException {
334         if (!isLinked(GARDEN_MAP)) {
335             return;
336         }
337         RawType cachedMap = this.cachedMap;
338         boolean mapRefreshed;
339         if (cachedMap == null
340                 || cachedMapTimestamp.isBefore(Instant.now().minus(Duration.ofDays(MAP_REFRESH_INTERVAL_DAYS)))) {
341             this.cachedMap = cachedMap = controller.getMap();
342             cachedMapTimestamp = Instant.now();
343             mapRefreshed = true;
344         } else {
345             mapRefreshed = false;
346         }
347         String svgMap = new String(cachedMap.getBytes(), StandardCharsets.UTF_8);
348         if (!svgMap.endsWith("</svg>")) {
349             if (mapRefreshed) {
350                 logger.warn("Unexpected map format, unable to plot location");
351                 logger.trace("Received map: {}", svgMap);
352                 updateState(GARDEN_MAP, cachedMap);
353             }
354             return;
355         }
356         svgMap = svgMap.substring(0, svgMap.length() - 6) + "<circle cx=\"" + xPos + "\" cy=\"" + yPos + "\" r=\""
357                 + MAP_POSITION_RADIUS + "\" stroke=\"" + MAP_POSITION_STROKE_COLOR + "\" fill=\""
358                 + MAP_POSITION_FILL_COLOR + "\" />\n</svg>";
359         updateState(GARDEN_MAP, new RawType(svgMap.getBytes(), cachedMap.getMimeType()));
360     }
361
362     private void updateState(DeviceStateResponse state) {
363         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
364         int status = getStatusFromCommand(deviceStatus.getAssociatedCommand());
365         int mowed = state.mowed;
366         int error = state.error;
367         int statecode = state.state;
368         boolean ready = isReadyToMow(deviceStatus, state.error);
369
370         updateState(STATECODE, new DecimalType(statecode));
371         updateState(READY, new DecimalType(ready ? 1 : 0));
372         updateState(ERRORCODE, new DecimalType(error));
373         updateState(MOWED, new PercentType(mowed));
374         updateState(STATE, new DecimalType(status));
375         updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage(translationProvider)));
376     }
377
378     private void updateOperatingData(OperatingDataResponse operatingData) {
379         updateState(BATTERY_VOLTAGE, new QuantityType<>(operatingData.battery.voltage, Units.VOLT));
380         updateState(BATTERY_LEVEL, new DecimalType(operatingData.battery.percent));
381         updateState(LOW_BATTERY, OnOffType.from(operatingData.battery.percent < 20));
382         updateState(BATTERY_TEMPERATURE, new QuantityType<>(operatingData.battery.batteryTemperature, SIUnits.CELSIUS));
383         updateState(GARDEN_SIZE, new QuantityType<>(operatingData.garden.size, SIUnits.SQUARE_METRE));
384     }
385
386     private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
387         return deviceStatus.isReadyToMow() && error == 0;
388     }
389
390     private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
391         // Mower reported an error
392         if (errorCode != 0) {
393             logger.error("The mower reported an error.");
394             return false;
395         }
396
397         // Command is equal to current state
398         if (command == deviceStatus.getAssociatedCommand()) {
399             logger.debug("Command is equal to state");
400             return false;
401         }
402         // Can't pause while the mower is docked
403         if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
404             logger.debug("Can't pause the mower while it's docked or docking");
405             return false;
406         }
407         // Command means "MOW" but mower is not ready
408         if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
409             logger.debug("The mower is not ready to mow at the moment");
410             return false;
411         }
412         return true;
413     }
414
415     private int getStatusFromCommand(@Nullable DeviceCommand command) {
416         if (command == null) {
417             return 0;
418         }
419         int status;
420         switch (command) {
421             case MOW:
422                 status = 1;
423                 break;
424             case RETURN:
425                 status = 2;
426                 break;
427             case PAUSE:
428                 status = 3;
429                 break;
430             default:
431                 status = 0;
432         }
433         return status;
434     }
435 }