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.util.Optional;
20 import java.util.concurrent.ScheduledFuture;
21 import java.util.concurrent.TimeUnit;
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;
57 * The {@link BoschIndegoHandler} is responsible for handling commands, which are
58 * sent to one of the channels.
60 * @author Jonas Fleck - Initial contribution
61 * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
64 public class BoschIndegoHandler extends BaseThingHandler {
66 private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
67 private final HttpClient httpClient;
68 private final BoschIndegoTranslationProvider translationProvider;
69 private final TimeZoneProvider timeZoneProvider;
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();
77 public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
78 TimeZoneProvider timeZoneProvider) {
80 this.httpClient = httpClient;
81 this.translationProvider = translationProvider;
82 this.timeZoneProvider = timeZoneProvider;
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;
92 if (username == null || username.isBlank()) {
93 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
94 "@text/offline.conf-error.missing-username");
97 if (password == null || password.isBlank()) {
98 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
99 "@text/offline.conf-error.missing-password");
103 controller = new IndegoController(httpClient, username, password);
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,
115 public void dispose() {
116 logger.debug("Disposing Indego handler");
117 ScheduledFuture<?> pollFuture = this.statePollFuture;
118 if (pollFuture != null) {
119 pollFuture.cancel(true);
121 this.statePollFuture = null;
122 pollFuture = this.cuttingTimeMapPollFuture;
123 if (pollFuture != null) {
124 pollFuture.cancel(true);
126 this.cuttingTimeMapPollFuture = null;
128 scheduler.execute(() -> {
130 controller.deauthenticate();
131 } catch (IndegoException e) {
132 logger.debug("Deauthentication failed", e);
138 public void handleCommand(ChannelUID channelUID, Command command) {
139 logger.debug("handleCommand {} for channel {}", command, channelUID);
141 if (command == RefreshType.REFRESH) {
142 handleRefreshCommand(channelUID.getId());
145 if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
146 sendCommand(((DecimalType) command).intValue());
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());
159 private void handleRefreshCommand(String channelId)
160 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
172 refreshCuttingTimes();
176 case BATTERY_VOLTAGE:
177 case BATTERY_TEMPERATURE:
179 refreshOperatingData();
187 private void sendCommand(int commandInt) throws IndegoException {
188 DeviceCommand command;
189 switch (commandInt) {
191 command = DeviceCommand.MOW;
194 command = DeviceCommand.RETURN;
197 command = DeviceCommand.PAUSE;
200 logger.warn("Invalid command {}", commandInt);
204 DeviceStateResponse state = controller.getState();
205 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
206 if (!verifyCommand(command, deviceStatus, state.error)) {
209 logger.debug("Sending command {}", command);
210 updateState(TEXTUAL_STATE, UnDefType.UNDEF);
211 controller.sendCommand(command);
215 private void refreshStateAndOperatingDataWithExceptionHandling() {
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());
230 private void refreshState() throws IndegoAuthenticationException, IndegoException {
231 if (!propertiesInitialized) {
232 getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
233 propertiesInitialized = true;
236 DeviceStateResponse state = controller.getState();
239 // When state code changed, refresh cutting times immediately.
240 if (previousStateCode.isPresent() && state.state != previousStateCode.get()) {
241 refreshCuttingTimes();
243 previousStateCode = Optional.of(state.state);
246 private void refreshOperatingData()
247 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
248 updateOperatingData(controller.getOperatingData());
249 updateStatus(ThingStatus.ONLINE);
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())));
259 updateState(LAST_CUTTING, UnDefType.UNDEF);
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())));
269 updateState(NEXT_CUTTING, UnDefType.UNDEF);
274 private void refreshCuttingTimesAndMapWithExceptionHandling() {
276 refreshCuttingTimes();
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());
286 private void refreshMap() throws IndegoAuthenticationException, IndegoException {
287 if (isLinked(GARDEN_MAP)) {
288 updateState(GARDEN_MAP, controller.getMap());
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);
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)));
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));
316 private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
317 return deviceStatus.isReadyToMow() && error == 0;
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.");
327 // Command is equal to current state
328 if (command == deviceStatus.getAssociatedCommand()) {
329 logger.debug("Command is equal to state");
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");
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");
345 private int getStatusFromCommand(@Nullable DeviceCommand command) {
346 if (command == null) {