]> git.basschouten.com Git - openhab-addons.git/blob
90ccf48b4abdf1fcc67dfc988ac4d9d2495cb158
[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.time.Instant;
18 import java.time.ZonedDateTime;
19 import java.time.temporal.ChronoUnit;
20 import java.util.Optional;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.openhab.binding.boschindego.internal.BoschIndegoTranslationProvider;
28 import org.openhab.binding.boschindego.internal.DeviceStatus;
29 import org.openhab.binding.boschindego.internal.IndegoController;
30 import org.openhab.binding.boschindego.internal.config.BoschIndegoConfiguration;
31 import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
32 import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
33 import org.openhab.binding.boschindego.internal.dto.response.OperatingDataResponse;
34 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
35 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
36 import org.openhab.binding.boschindego.internal.exceptions.IndegoUnreachableException;
37 import org.openhab.core.i18n.TimeZoneProvider;
38 import org.openhab.core.library.types.DateTimeType;
39 import org.openhab.core.library.types.DecimalType;
40 import org.openhab.core.library.types.OnOffType;
41 import org.openhab.core.library.types.PercentType;
42 import org.openhab.core.library.types.QuantityType;
43 import org.openhab.core.library.types.StringType;
44 import org.openhab.core.library.unit.SIUnits;
45 import org.openhab.core.library.unit.Units;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.UnDefType;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
56
57 /**
58  * The {@link BoschIndegoHandler} is responsible for handling commands, which are
59  * sent to one of the channels.
60  *
61  * @author Jonas Fleck - Initial contribution
62  * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
63  */
64 @NonNullByDefault
65 public class BoschIndegoHandler extends BaseThingHandler {
66
67     private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
68     private final HttpClient httpClient;
69     private final BoschIndegoTranslationProvider translationProvider;
70     private final TimeZoneProvider timeZoneProvider;
71
72     private @NonNullByDefault({}) IndegoController controller;
73     private @Nullable ScheduledFuture<?> statePollFuture;
74     private @Nullable ScheduledFuture<?> cuttingTimeMapPollFuture;
75     private @Nullable ScheduledFuture<?> cuttingTimeFuture;
76     private boolean propertiesInitialized;
77     private Optional<Integer> previousStateCode = Optional.empty();
78
79     public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
80             TimeZoneProvider timeZoneProvider) {
81         super(thing);
82         this.httpClient = httpClient;
83         this.translationProvider = translationProvider;
84         this.timeZoneProvider = timeZoneProvider;
85     }
86
87     @Override
88     public void initialize() {
89         logger.debug("Initializing Indego handler");
90         BoschIndegoConfiguration config = getConfigAs(BoschIndegoConfiguration.class);
91         String username = config.username;
92         String password = config.password;
93
94         if (username == null || username.isBlank()) {
95             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
96                     "@text/offline.conf-error.missing-username");
97             return;
98         }
99         if (password == null || password.isBlank()) {
100             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
101                     "@text/offline.conf-error.missing-password");
102             return;
103         }
104
105         controller = new IndegoController(httpClient, username, password);
106
107         updateStatus(ThingStatus.UNKNOWN);
108         previousStateCode = Optional.empty();
109         this.statePollFuture = scheduler.scheduleWithFixedDelay(this::refreshStateAndOperatingDataWithExceptionHandling,
110                 0, config.refresh, TimeUnit.SECONDS);
111         this.cuttingTimeMapPollFuture = scheduler.scheduleWithFixedDelay(
112                 this::refreshCuttingTimesAndMapWithExceptionHandling, 0, config.cuttingTimeMapRefresh,
113                 TimeUnit.MINUTES);
114     }
115
116     @Override
117     public void dispose() {
118         logger.debug("Disposing Indego handler");
119         ScheduledFuture<?> pollFuture = this.statePollFuture;
120         if (pollFuture != null) {
121             pollFuture.cancel(true);
122         }
123         this.statePollFuture = null;
124         pollFuture = this.cuttingTimeMapPollFuture;
125         if (pollFuture != null) {
126             pollFuture.cancel(true);
127         }
128         this.cuttingTimeMapPollFuture = null;
129         pollFuture = this.cuttingTimeFuture;
130         if (pollFuture != null) {
131             pollFuture.cancel(true);
132         }
133         this.cuttingTimeFuture = null;
134
135         scheduler.execute(() -> {
136             try {
137                 controller.deauthenticate();
138             } catch (IndegoException e) {
139                 logger.debug("Deauthentication failed", e);
140             }
141         });
142     }
143
144     @Override
145     public void handleCommand(ChannelUID channelUID, Command command) {
146         logger.debug("handleCommand {} for channel {}", command, channelUID);
147         try {
148             if (command == RefreshType.REFRESH) {
149                 handleRefreshCommand(channelUID.getId());
150                 return;
151             }
152             if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
153                 sendCommand(((DecimalType) command).intValue());
154             }
155         } catch (IndegoAuthenticationException e) {
156             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
157                     "@text/offline.comm-error.authentication-failure");
158         } catch (IndegoUnreachableException e) {
159             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
160                     "@text/offline.comm-error.unreachable");
161         } catch (IndegoException e) {
162             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
163         }
164     }
165
166     private void handleRefreshCommand(String channelId)
167             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
168         switch (channelId) {
169             case STATE:
170             case TEXTUAL_STATE:
171             case MOWED:
172             case ERRORCODE:
173             case STATECODE:
174             case READY:
175                 refreshState();
176                 break;
177             case LAST_CUTTING:
178             case NEXT_CUTTING:
179                 refreshCuttingTimes();
180                 break;
181             case BATTERY_LEVEL:
182             case LOW_BATTERY:
183             case BATTERY_VOLTAGE:
184             case BATTERY_TEMPERATURE:
185             case GARDEN_SIZE:
186                 refreshOperatingData();
187                 break;
188             case GARDEN_MAP:
189                 refreshMap();
190                 break;
191         }
192     }
193
194     private void sendCommand(int commandInt) throws IndegoException {
195         DeviceCommand command;
196         switch (commandInt) {
197             case 1:
198                 command = DeviceCommand.MOW;
199                 break;
200             case 2:
201                 command = DeviceCommand.RETURN;
202                 break;
203             case 3:
204                 command = DeviceCommand.PAUSE;
205                 break;
206             default:
207                 logger.warn("Invalid command {}", commandInt);
208                 return;
209         }
210
211         DeviceStateResponse state = controller.getState();
212         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
213         if (!verifyCommand(command, deviceStatus, state.error)) {
214             return;
215         }
216         logger.debug("Sending command {}", command);
217         updateState(TEXTUAL_STATE, UnDefType.UNDEF);
218         controller.sendCommand(command);
219         refreshState();
220     }
221
222     private void refreshStateAndOperatingDataWithExceptionHandling() {
223         try {
224             refreshState();
225             refreshOperatingData();
226         } catch (IndegoAuthenticationException e) {
227             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
228                     "@text/offline.comm-error.authentication-failure");
229         } catch (IndegoUnreachableException e) {
230             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
231                     "@text/offline.comm-error.unreachable");
232         } catch (IndegoException e) {
233             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
234         }
235     }
236
237     private void refreshState() throws IndegoAuthenticationException, IndegoException {
238         if (!propertiesInitialized) {
239             getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
240             propertiesInitialized = true;
241         }
242
243         DeviceStateResponse state = controller.getState();
244         updateState(state);
245
246         // When state code changed, refresh cutting times immediately.
247         if (previousStateCode.isPresent() && state.state != previousStateCode.get()) {
248             refreshCuttingTimes();
249         }
250         previousStateCode = Optional.of(state.state);
251     }
252
253     private void refreshOperatingData()
254             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
255         updateOperatingData(controller.getOperatingData());
256         updateStatus(ThingStatus.ONLINE);
257     }
258
259     private void refreshCuttingTimesWithExceptionHandling() {
260         try {
261             refreshCuttingTimes();
262         } catch (IndegoAuthenticationException e) {
263             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
264                     "@text/offline.comm-error.authentication-failure");
265         } catch (IndegoException e) {
266             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
267         }
268     }
269
270     private void refreshCuttingTimes() throws IndegoAuthenticationException, IndegoException {
271         if (isLinked(LAST_CUTTING)) {
272             Instant lastCutting = controller.getPredictiveLastCutting();
273             if (lastCutting != null) {
274                 updateState(LAST_CUTTING,
275                         new DateTimeType(ZonedDateTime.ofInstant(lastCutting, timeZoneProvider.getTimeZone())));
276             } else {
277                 updateState(LAST_CUTTING, UnDefType.UNDEF);
278             }
279         }
280
281         cancelCuttingTimeRefresh();
282         if (isLinked(NEXT_CUTTING)) {
283             Instant nextCutting = controller.getPredictiveNextCutting();
284             if (nextCutting != null) {
285                 updateState(NEXT_CUTTING,
286                         new DateTimeType(ZonedDateTime.ofInstant(nextCutting, timeZoneProvider.getTimeZone())));
287                 scheduleCuttingTimesRefresh(nextCutting);
288             } else {
289                 updateState(NEXT_CUTTING, UnDefType.UNDEF);
290             }
291         }
292     }
293
294     private void cancelCuttingTimeRefresh() {
295         ScheduledFuture<?> cuttingTimeFuture = this.cuttingTimeFuture;
296         if (cuttingTimeFuture != null) {
297             // Do not interrupt as we might be running within that job.
298             cuttingTimeFuture.cancel(false);
299             this.cuttingTimeFuture = null;
300         }
301     }
302
303     private void scheduleCuttingTimesRefresh(Instant nextCutting) {
304         // Schedule additional update right after next planned cutting. This ensures a faster update
305         // in case the next cutting will be postponed (for example due to weather conditions).
306         long secondsUntilNextCutting = Instant.now().until(nextCutting, ChronoUnit.SECONDS) + 2;
307         if (secondsUntilNextCutting > 0) {
308             logger.debug("Scheduling fetching of cutting times in {} seconds", secondsUntilNextCutting);
309             this.cuttingTimeFuture = scheduler.schedule(this::refreshCuttingTimesWithExceptionHandling,
310                     secondsUntilNextCutting, TimeUnit.SECONDS);
311         }
312     }
313
314     private void refreshCuttingTimesAndMapWithExceptionHandling() {
315         try {
316             refreshCuttingTimes();
317             refreshMap();
318         } catch (IndegoAuthenticationException e) {
319             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
320                     "@text/offline.comm-error.authentication-failure");
321         } catch (IndegoException e) {
322             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
323         }
324     }
325
326     private void refreshMap() throws IndegoAuthenticationException, IndegoException {
327         if (isLinked(GARDEN_MAP)) {
328             updateState(GARDEN_MAP, controller.getMap());
329         }
330     }
331
332     private void updateState(DeviceStateResponse state) {
333         DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
334         int status = getStatusFromCommand(deviceStatus.getAssociatedCommand());
335         int mowed = state.mowed;
336         int error = state.error;
337         int statecode = state.state;
338         boolean ready = isReadyToMow(deviceStatus, state.error);
339
340         updateState(STATECODE, new DecimalType(statecode));
341         updateState(READY, new DecimalType(ready ? 1 : 0));
342         updateState(ERRORCODE, new DecimalType(error));
343         updateState(MOWED, new PercentType(mowed));
344         updateState(STATE, new DecimalType(status));
345         updateState(TEXTUAL_STATE, new StringType(deviceStatus.getMessage(translationProvider)));
346     }
347
348     private void updateOperatingData(OperatingDataResponse operatingData) {
349         updateState(BATTERY_VOLTAGE, new QuantityType<>(operatingData.battery.voltage, Units.VOLT));
350         updateState(BATTERY_LEVEL, new DecimalType(operatingData.battery.percent));
351         updateState(LOW_BATTERY, OnOffType.from(operatingData.battery.percent < 20));
352         updateState(BATTERY_TEMPERATURE, new QuantityType<>(operatingData.battery.batteryTemperature, SIUnits.CELSIUS));
353         updateState(GARDEN_SIZE, new QuantityType<>(operatingData.garden.size, SIUnits.SQUARE_METRE));
354     }
355
356     private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
357         return deviceStatus.isReadyToMow() && error == 0;
358     }
359
360     private boolean verifyCommand(DeviceCommand command, DeviceStatus deviceStatus, int errorCode) {
361         // Mower reported an error
362         if (errorCode != 0) {
363             logger.error("The mower reported an error.");
364             return false;
365         }
366
367         // Command is equal to current state
368         if (command == deviceStatus.getAssociatedCommand()) {
369             logger.debug("Command is equal to state");
370             return false;
371         }
372         // Can't pause while the mower is docked
373         if (command == DeviceCommand.PAUSE && deviceStatus.getAssociatedCommand() == DeviceCommand.RETURN) {
374             logger.debug("Can't pause the mower while it's docked or docking");
375             return false;
376         }
377         // Command means "MOW" but mower is not ready
378         if (command == DeviceCommand.MOW && !isReadyToMow(deviceStatus, errorCode)) {
379             logger.debug("The mower is not ready to mow at the moment");
380             return false;
381         }
382         return true;
383     }
384
385     private int getStatusFromCommand(@Nullable DeviceCommand command) {
386         if (command == null) {
387             return 0;
388         }
389         int status;
390         switch (command) {
391             case MOW:
392                 status = 1;
393                 break;
394             case RETURN:
395                 status = 2;
396                 break;
397             case PAUSE:
398                 status = 3;
399                 break;
400             default:
401                 status = 0;
402         }
403         return status;
404     }
405 }