2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.boschindego.internal.handler;
15 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
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;
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;
58 * The {@link BoschIndegoHandler} is responsible for handling commands, which are
59 * sent to one of the channels.
61 * @author Jonas Fleck - Initial contribution
62 * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
65 public class BoschIndegoHandler extends BaseThingHandler {
67 private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
68 private final HttpClient httpClient;
69 private final BoschIndegoTranslationProvider translationProvider;
70 private final TimeZoneProvider timeZoneProvider;
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();
79 public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
80 TimeZoneProvider timeZoneProvider) {
82 this.httpClient = httpClient;
83 this.translationProvider = translationProvider;
84 this.timeZoneProvider = timeZoneProvider;
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;
94 if (username == null || username.isBlank()) {
95 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
96 "@text/offline.conf-error.missing-username");
99 if (password == null || password.isBlank()) {
100 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
101 "@text/offline.conf-error.missing-password");
105 controller = new IndegoController(httpClient, username, password);
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,
117 public void dispose() {
118 logger.debug("Disposing Indego handler");
119 ScheduledFuture<?> pollFuture = this.statePollFuture;
120 if (pollFuture != null) {
121 pollFuture.cancel(true);
123 this.statePollFuture = null;
124 pollFuture = this.cuttingTimeMapPollFuture;
125 if (pollFuture != null) {
126 pollFuture.cancel(true);
128 this.cuttingTimeMapPollFuture = null;
129 pollFuture = this.cuttingTimeFuture;
130 if (pollFuture != null) {
131 pollFuture.cancel(true);
133 this.cuttingTimeFuture = null;
135 scheduler.execute(() -> {
137 controller.deauthenticate();
138 } catch (IndegoException e) {
139 logger.debug("Deauthentication failed", e);
145 public void handleCommand(ChannelUID channelUID, Command command) {
146 logger.debug("handleCommand {} for channel {}", command, channelUID);
148 if (command == RefreshType.REFRESH) {
149 handleRefreshCommand(channelUID.getId());
152 if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
153 sendCommand(((DecimalType) command).intValue());
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());
166 private void handleRefreshCommand(String channelId)
167 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
179 refreshCuttingTimes();
183 case BATTERY_VOLTAGE:
184 case BATTERY_TEMPERATURE:
186 refreshOperatingData();
194 private void sendCommand(int commandInt) throws IndegoException {
195 DeviceCommand command;
196 switch (commandInt) {
198 command = DeviceCommand.MOW;
201 command = DeviceCommand.RETURN;
204 command = DeviceCommand.PAUSE;
207 logger.warn("Invalid command {}", commandInt);
211 DeviceStateResponse state = controller.getState();
212 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
213 if (!verifyCommand(command, deviceStatus, state.error)) {
216 logger.debug("Sending command {}", command);
217 updateState(TEXTUAL_STATE, UnDefType.UNDEF);
218 controller.sendCommand(command);
222 private void refreshStateAndOperatingDataWithExceptionHandling() {
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());
237 private void refreshState() throws IndegoAuthenticationException, IndegoException {
238 if (!propertiesInitialized) {
239 getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
240 propertiesInitialized = true;
243 DeviceStateResponse state = controller.getState();
246 // When state code changed, refresh cutting times immediately.
247 if (previousStateCode.isPresent() && state.state != previousStateCode.get()) {
248 refreshCuttingTimes();
250 previousStateCode = Optional.of(state.state);
253 private void refreshOperatingData()
254 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
255 updateOperatingData(controller.getOperatingData());
256 updateStatus(ThingStatus.ONLINE);
259 private void refreshCuttingTimesWithExceptionHandling() {
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());
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())));
277 updateState(LAST_CUTTING, UnDefType.UNDEF);
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);
289 updateState(NEXT_CUTTING, UnDefType.UNDEF);
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;
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);
314 private void refreshCuttingTimesAndMapWithExceptionHandling() {
316 refreshCuttingTimes();
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());
326 private void refreshMap() throws IndegoAuthenticationException, IndegoException {
327 if (isLinked(GARDEN_MAP)) {
328 updateState(GARDEN_MAP, controller.getMap());
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);
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)));
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));
356 private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
357 return deviceStatus.isReadyToMow() && error == 0;
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.");
367 // Command is equal to current state
368 if (command == deviceStatus.getAssociatedCommand()) {
369 logger.debug("Command is equal to state");
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");
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");
385 private int getStatusFromCommand(@Nullable DeviceCommand command) {
386 if (command == null) {