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