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.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;
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;
61 * The {@link BoschIndegoHandler} is responsible for handling commands, which are
62 * sent to one of the channels.
64 * @author Jonas Fleck - Initial contribution
65 * @author Jacob Laursen - Refactoring, bugfixing and removal of dependency towards abandoned library
68 public class BoschIndegoHandler extends BaseThingHandler {
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;
75 private final Logger logger = LoggerFactory.getLogger(BoschIndegoHandler.class);
76 private final HttpClient httpClient;
77 private final BoschIndegoTranslationProvider translationProvider;
78 private final TimeZoneProvider timeZoneProvider;
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;
89 public BoschIndegoHandler(Thing thing, HttpClient httpClient, BoschIndegoTranslationProvider translationProvider,
90 TimeZoneProvider timeZoneProvider) {
92 this.httpClient = httpClient;
93 this.translationProvider = translationProvider;
94 this.timeZoneProvider = timeZoneProvider;
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;
104 if (username == null || username.isBlank()) {
105 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
106 "@text/offline.conf-error.missing-username");
109 if (password == null || password.isBlank()) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
111 "@text/offline.conf-error.missing-password");
115 controller = new IndegoController(httpClient, username, password);
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);
126 public void dispose() {
127 logger.debug("Disposing Indego handler");
128 ScheduledFuture<?> pollFuture = this.statePollFuture;
129 if (pollFuture != null) {
130 pollFuture.cancel(true);
132 this.statePollFuture = null;
133 pollFuture = this.cuttingTimePollFuture;
134 if (pollFuture != null) {
135 pollFuture.cancel(true);
137 this.cuttingTimePollFuture = null;
138 pollFuture = this.cuttingTimeFuture;
139 if (pollFuture != null) {
140 pollFuture.cancel(true);
142 this.cuttingTimeFuture = null;
144 scheduler.execute(() -> {
146 controller.deauthenticate();
147 } catch (IndegoException e) {
148 logger.debug("Deauthentication failed", e);
154 public void handleCommand(ChannelUID channelUID, Command command) {
155 logger.debug("handleCommand {} for channel {}", command, channelUID);
157 if (command == RefreshType.REFRESH) {
158 handleRefreshCommand(channelUID.getId());
161 if (command instanceof DecimalType && channelUID.getId().equals(STATE)) {
162 sendCommand(((DecimalType) command).intValue());
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());
175 private void handleRefreshCommand(String channelId)
176 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
179 // Force map refresh and fall through to state update.
180 cachedMapTimestamp = Instant.MIN;
191 refreshCuttingTimes();
195 case BATTERY_VOLTAGE:
196 case BATTERY_TEMPERATURE:
198 refreshOperatingData();
203 private void sendCommand(int commandInt) throws IndegoException {
204 DeviceCommand command;
205 switch (commandInt) {
207 command = DeviceCommand.MOW;
210 command = DeviceCommand.RETURN;
213 command = DeviceCommand.PAUSE;
216 logger.warn("Invalid command {}", commandInt);
220 DeviceStateResponse state = controller.getState();
221 DeviceStatus deviceStatus = DeviceStatus.fromCode(state.state);
222 if (!verifyCommand(command, deviceStatus, state.error)) {
225 logger.debug("Sending command {}", command);
226 updateState(TEXTUAL_STATE, UnDefType.UNDEF);
227 controller.sendCommand(command);
231 private void refreshStateAndOperatingDataWithExceptionHandling() {
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());
246 private void refreshState() throws IndegoAuthenticationException, IndegoException {
247 if (!propertiesInitialized) {
248 getThing().setProperty(Thing.PROPERTY_SERIAL_NUMBER, controller.getSerialNumber());
249 propertiesInitialized = true;
252 DeviceStateResponse state = controller.getState();
255 if (state.mapUpdateAvailable) {
256 cachedMapTimestamp = Instant.MIN;
258 refreshMap(state.svgXPos, state.svgYPos);
260 // When state code changed, refresh cutting times immediately.
261 if (previousStateCode.isPresent() && state.state != previousStateCode.get()) {
262 refreshCuttingTimes();
264 // After learning lawn, trigger a forced map refresh on next poll.
265 if (previousStateCode.get() == DeviceStatus.STATE_LEARNING_LAWN) {
266 cachedMapTimestamp = Instant.MIN;
269 previousStateCode = Optional.of(state.state);
272 private void refreshOperatingData()
273 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
274 updateOperatingData(controller.getOperatingData());
275 updateStatus(ThingStatus.ONLINE);
278 private void refreshCuttingTimesWithExceptionHandling() {
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());
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())));
296 updateState(LAST_CUTTING, UnDefType.UNDEF);
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);
308 updateState(NEXT_CUTTING, UnDefType.UNDEF);
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;
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);
333 private void refreshMap(int xPos, int yPos) throws IndegoAuthenticationException, IndegoException {
334 if (!isLinked(GARDEN_MAP)) {
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();
345 mapRefreshed = false;
347 String svgMap = new String(cachedMap.getBytes(), StandardCharsets.UTF_8);
348 if (!svgMap.endsWith("</svg>")) {
350 logger.warn("Unexpected map format, unable to plot location");
351 logger.trace("Received map: {}", svgMap);
352 updateState(GARDEN_MAP, cachedMap);
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()));
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);
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)));
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));
386 private boolean isReadyToMow(DeviceStatus deviceStatus, int error) {
387 return deviceStatus.isReadyToMow() && error == 0;
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.");
397 // Command is equal to current state
398 if (command == deviceStatus.getAssociatedCommand()) {
399 logger.debug("Command is equal to state");
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");
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");
415 private int getStatusFromCommand(@Nullable DeviceCommand command) {
416 if (command == null) {