2 * Copyright (c) 2010-2023 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.robonect.internal.handler;
15 import static org.openhab.binding.robonect.internal.RobonectBindingConstants.*;
17 import java.time.DateTimeException;
18 import java.time.Instant;
19 import java.time.ZoneId;
20 import java.time.ZoneOffset;
21 import java.time.ZonedDateTime;
22 import java.util.List;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.openhab.binding.robonect.internal.RobonectClient;
29 import org.openhab.binding.robonect.internal.RobonectCommunicationException;
30 import org.openhab.binding.robonect.internal.RobonectEndpoint;
31 import org.openhab.binding.robonect.internal.config.JobChannelConfig;
32 import org.openhab.binding.robonect.internal.config.RobonectConfig;
33 import org.openhab.binding.robonect.internal.model.ErrorEntry;
34 import org.openhab.binding.robonect.internal.model.ErrorList;
35 import org.openhab.binding.robonect.internal.model.MowerInfo;
36 import org.openhab.binding.robonect.internal.model.Name;
37 import org.openhab.binding.robonect.internal.model.RobonectAnswer;
38 import org.openhab.binding.robonect.internal.model.VersionInfo;
39 import org.openhab.binding.robonect.internal.model.cmd.ModeCommand;
40 import org.openhab.core.i18n.TimeZoneProvider;
41 import org.openhab.core.library.types.DateTimeType;
42 import org.openhab.core.library.types.DecimalType;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.QuantityType;
45 import org.openhab.core.library.types.StringType;
46 import org.openhab.core.library.unit.SIUnits;
47 import org.openhab.core.library.unit.Units;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.thing.binding.BaseThingHandler;
53 import org.openhab.core.types.Command;
54 import org.openhab.core.types.RefreshType;
55 import org.openhab.core.types.State;
56 import org.openhab.core.types.UnDefType;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
60 import com.google.gson.JsonSyntaxException;
63 * The {@link RobonectHandler} is responsible for handling commands, which are
64 * sent to one of the channels.
66 * The channels are periodically updated by polling the mower via HTTP in a separate thread.
68 * @author Marco Meyer - Initial contribution
70 public class RobonectHandler extends BaseThingHandler {
72 private final Logger logger = LoggerFactory.getLogger(RobonectHandler.class);
74 private ScheduledFuture<?> pollingJob;
76 private HttpClient httpClient;
77 private TimeZoneProvider timeZoneProvider;
79 private ZoneId timeZone;
81 private RobonectClient robonectClient;
83 public RobonectHandler(Thing thing, HttpClient httpClient, TimeZoneProvider timeZoneProvider) {
85 this.httpClient = httpClient;
86 this.timeZoneProvider = timeZoneProvider;
90 public void handleCommand(ChannelUID channelUID, Command command) {
92 if (command instanceof RefreshType) {
93 refreshChannels(channelUID);
95 sendCommand(channelUID, command);
97 updateStatus(ThingStatus.ONLINE);
98 } catch (RobonectCommunicationException rce) {
99 logger.debug("Failed to communicate with the mower. Taking it offline.", rce);
100 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, rce.getMessage());
104 private void sendCommand(ChannelUID channelUID, Command command) {
105 switch (channelUID.getId()) {
106 case CHANNEL_MOWER_NAME:
107 if (command instanceof StringType) {
108 updateName((StringType) command);
110 logger.debug("Got name update of type {} but StringType is expected.",
111 command.getClass().getName());
115 case CHANNEL_STATUS_MODE:
116 if (command instanceof StringType) {
117 setMowerMode(command);
119 logger.debug("Got job remote start update of type {} but StringType is expected.",
120 command.getClass().getName());
124 case CHANNEL_MOWER_START:
125 if (command instanceof OnOffType) {
126 handleStartStop((OnOffType) command);
128 logger.debug("Got stopped update of type {} but OnOffType is expected.",
129 command.getClass().getName());
134 if (command instanceof OnOffType) {
135 handleJobCommand(channelUID, command);
137 logger.debug("Got job update of type {} but OnOffType is expected.", command.getClass().getName());
143 private void handleJobCommand(ChannelUID channelUID, Command command) {
144 JobChannelConfig jobConfig = getThing().getChannel(channelUID.getId()).getConfiguration()
145 .as(JobChannelConfig.class);
146 if (command == OnOffType.ON) {
147 robonectClient.startJob(
148 new RobonectClient.JobSettings().withAfterMode(ModeCommand.Mode.valueOf(jobConfig.getAfterMode()))
149 .withRemoteStart(ModeCommand.RemoteStart.valueOf(jobConfig.getRemoteStart()))
150 .withDuration(jobConfig.getDuration()));
151 } else if (command == OnOffType.OFF) {
152 robonectClient.stopJob(
153 new RobonectClient.JobSettings().withAfterMode(ModeCommand.Mode.valueOf(jobConfig.getAfterMode())));
157 private void refreshChannels(ChannelUID channelUID) {
158 switch (channelUID.getId()) {
159 case CHANNEL_MOWER_NAME:
160 case CHANNEL_STATUS_BATTERY:
162 case CHANNEL_STATUS_DURATION:
163 case CHANNEL_STATUS_HOURS:
164 case CHANNEL_STATUS_MODE:
165 case CHANNEL_MOWER_START:
166 case CHANNEL_TIMER_NEXT_TIMER:
167 case CHANNEL_TIMER_STATUS:
168 case CHANNEL_WLAN_SIGNAL:
173 case CHANNEL_LAST_ERROR_CODE:
174 case CHANNEL_LAST_ERROR_DATE:
175 case CHANNEL_LAST_ERROR_MESSAGE:
176 refreshLastErrorInfo();
181 private void setMowerMode(Command command) {
182 String modeStr = command.toFullString();
183 ModeCommand.Mode newMode = ModeCommand.Mode.valueOf(modeStr.toUpperCase());
184 if (robonectClient.setMode(newMode).isSuccessful()) {
185 updateState(CHANNEL_STATUS_MODE, new StringType(newMode.name()));
191 private void logErrorFromResponse(RobonectAnswer result) {
192 if (!result.isSuccessful()) {
193 logger.debug("Could not send EOD Trigger. Robonect error message: {}", result.getErrorMessage());
197 private void handleStartStop(final OnOffType command) {
198 RobonectAnswer answer = null;
199 boolean currentlyStopped = robonectClient.getMowerInfo().getStatus().isStopped();
200 if (command == OnOffType.ON && currentlyStopped) {
201 answer = robonectClient.start();
202 } else if (command == OnOffType.OFF && !currentlyStopped) {
203 answer = robonectClient.stop();
205 if (answer != null) {
206 if (answer.isSuccessful()) {
207 updateState(CHANNEL_MOWER_START, command);
209 logErrorFromResponse(answer);
215 private void updateName(StringType command) {
216 String newName = command.toFullString();
217 Name name = robonectClient.setName(newName);
218 if (name.isSuccessful()) {
219 updateState(CHANNEL_MOWER_NAME, new StringType(name.getName()));
221 logErrorFromResponse(name);
226 private void refreshMowerInfo() {
227 MowerInfo info = robonectClient.getMowerInfo();
228 if (info.isSuccessful()) {
229 if (info.getError() != null) {
230 updateErrorInfo(info.getError());
231 refreshLastErrorInfo();
235 updateState(CHANNEL_MOWER_NAME, new StringType(info.getName()));
236 updateState(CHANNEL_STATUS_BATTERY, new DecimalType(info.getStatus().getBattery()));
237 updateState(CHANNEL_STATUS, new DecimalType(info.getStatus().getStatus().getStatusCode()));
238 updateState(CHANNEL_STATUS_DURATION, new QuantityType<>(info.getStatus().getDuration(), Units.SECOND));
239 updateState(CHANNEL_STATUS_DISTANCE, new QuantityType<>(info.getStatus().getDistance(), SIUnits.METRE));
240 updateState(CHANNEL_STATUS_HOURS, new QuantityType<>(info.getStatus().getHours(), Units.HOUR));
241 updateState(CHANNEL_STATUS_MODE, new StringType(info.getStatus().getMode().name()));
242 updateState(CHANNEL_MOWER_START, info.getStatus().isStopped() ? OnOffType.OFF : OnOffType.ON);
243 if (info.getHealth() != null) {
244 updateState(CHANNEL_HEALTH_TEMP,
245 new QuantityType<>(info.getHealth().getTemperature(), SIUnits.CELSIUS));
246 updateState(CHANNEL_HEALTH_HUM, new QuantityType(info.getHealth().getHumidity(), Units.PERCENT));
248 if (info.getTimer() != null) {
249 if (info.getTimer().getNext() != null) {
250 updateNextTimer(info);
252 updateState(CHANNEL_TIMER_STATUS, new StringType(info.getTimer().getStatus().name()));
254 updateState(CHANNEL_WLAN_SIGNAL, new DecimalType(info.getWlan().getSignal()));
255 updateState(CHANNEL_JOB, robonectClient.isJobRunning() ? OnOffType.ON : OnOffType.OFF);
257 logger.error("Could not retrieve mower info. Robonect error response message: {}", info.getErrorMessage());
261 private void clearErrorInfo() {
262 updateState(CHANNEL_ERROR_DATE, UnDefType.UNDEF);
263 updateState(CHANNEL_ERROR_CODE, UnDefType.UNDEF);
264 updateState(CHANNEL_ERROR_MESSAGE, UnDefType.UNDEF);
267 private void updateErrorInfo(ErrorEntry error) {
268 if (error.getErrorMessage() != null) {
269 updateState(CHANNEL_ERROR_MESSAGE, new StringType(error.getErrorMessage()));
271 if (error.getErrorCode() != null) {
272 updateState(CHANNEL_ERROR_CODE, new DecimalType(error.getErrorCode().intValue()));
274 if (error.getDate() != null) {
275 State dateTime = convertUnixToDateTimeType(error.getUnix());
276 updateState(CHANNEL_ERROR_DATE, dateTime);
280 private void updateNextTimer(MowerInfo info) {
281 State dateTime = convertUnixToDateTimeType(info.getTimer().getNext().getUnix());
282 updateState(CHANNEL_TIMER_NEXT_TIMER, dateTime);
285 private State convertUnixToDateTimeType(String unixTimeSec) {
286 // the value in unixTimeSec represents the time on the robot in its configured timezone. However, it does not
287 // provide which zone this is. Thus we have to add the zone information from the Thing configuration in order to
288 // provide correct results.
289 Instant rawInstant = Instant.ofEpochMilli(Long.valueOf(unixTimeSec) * 1000);
291 ZoneId timeZoneOfThing = timeZone;
292 if (timeZoneOfThing == null) {
293 timeZoneOfThing = timeZoneProvider.getTimeZone();
295 ZoneOffset offsetToConfiguredZone = timeZoneOfThing.getRules().getOffset(rawInstant);
296 long adjustedTime = rawInstant.getEpochSecond() - offsetToConfiguredZone.getTotalSeconds();
297 Instant adjustedInstant = Instant.ofEpochMilli(adjustedTime * 1000);
299 // we provide the time in the format as configured in the openHAB settings
300 ZonedDateTime zdt = adjustedInstant.atZone(timeZoneProvider.getTimeZone());
301 return new DateTimeType(zdt);
304 private void refreshVersionInfo() {
305 VersionInfo info = robonectClient.getVersionInfo();
306 if (info.isSuccessful()) {
307 Map<String, String> properties = editProperties();
308 properties.put(Thing.PROPERTY_SERIAL_NUMBER, info.getRobonect().getSerial());
309 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.getRobonect().getVersion());
310 properties.put(PROPERTY_COMPILED, info.getRobonect().getCompiled());
311 properties.put(PROPERTY_COMMENT, info.getRobonect().getComment());
312 updateProperties(properties);
314 logger.debug("Could not retrieve mower version info. Robonect error response message: {}",
315 info.getErrorMessage());
319 private void refreshLastErrorInfo() {
320 ErrorList errorList = robonectClient.errorList();
321 if (errorList.isSuccessful()) {
322 List<ErrorEntry> errors = errorList.getErrors();
323 if (errors != null && !errors.isEmpty()) {
324 ErrorEntry lastErrorEntry = errors.get(0);
325 updateLastErrorChannels(lastErrorEntry);
328 logger.debug("Could not retrieve mower error list. Robonect error response message: {}",
329 errorList.getErrorMessage());
333 private void updateLastErrorChannels(ErrorEntry error) {
334 if (error.getErrorMessage() != null) {
335 updateState(CHANNEL_LAST_ERROR_MESSAGE, new StringType(error.getErrorMessage()));
337 if (error.getErrorCode() != null) {
338 updateState(CHANNEL_LAST_ERROR_CODE, new DecimalType(error.getErrorCode().intValue()));
340 if (error.getDate() != null) {
341 State dateTime = convertUnixToDateTimeType(error.getUnix());
342 updateState(CHANNEL_LAST_ERROR_DATE, dateTime);
347 public void initialize() {
348 RobonectConfig robonectConfig = getConfigAs(RobonectConfig.class);
349 RobonectEndpoint endpoint = new RobonectEndpoint(robonectConfig.getHost(), robonectConfig.getUser(),
350 robonectConfig.getPassword());
352 String timeZoneString = robonectConfig.getTimezone();
354 if (timeZoneString != null) {
355 timeZone = ZoneId.of(timeZoneString);
357 logger.warn("No timezone provided, falling back to the default timezone configured in openHAB: '{}'",
358 timeZoneProvider.getTimeZone());
360 } catch (DateTimeException e) {
361 logger.warn("Error setting timezone '{}', falling back to the default timezone configured in openHAB: '{}'",
362 timeZoneString, timeZoneProvider.getTimeZone(), e);
367 robonectClient = new RobonectClient(httpClient, endpoint);
368 } catch (Exception e) {
369 logger.error("Exception while trying to start http client", e);
370 throw new RuntimeException("Exception while trying to start http client", e);
372 Runnable runnable = new MowerChannelPoller(TimeUnit.SECONDS.toMillis(robonectConfig.getOfflineTimeout()));
373 int pollInterval = robonectConfig.getPollInterval();
374 pollingJob = scheduler.scheduleWithFixedDelay(runnable, 0, pollInterval, TimeUnit.SECONDS);
378 public void dispose() {
379 if (pollingJob != null) {
380 pollingJob.cancel(true);
388 * method to inject the robonect client to be used in test cases to allow mocking.
390 * @param robonectClient
392 protected void setRobonectClient(RobonectClient robonectClient) {
393 this.robonectClient = robonectClient;
396 private class MowerChannelPoller implements Runnable {
398 private long offlineSince;
399 private long offlineTriggerDelay;
400 private boolean offlineTimeoutTriggered;
401 private boolean loadVersionInfo = true;
403 public MowerChannelPoller(long offlineTriggerDelay) {
405 this.offlineTriggerDelay = offlineTriggerDelay;
406 offlineTimeoutTriggered = false;
412 if (loadVersionInfo) {
413 refreshVersionInfo();
414 loadVersionInfo = false;
417 updateStatus(ThingStatus.ONLINE);
419 offlineTimeoutTriggered = false;
420 } catch (RobonectCommunicationException rce) {
421 if (offlineSince < 0) {
422 offlineSince = System.currentTimeMillis();
424 if (!offlineTimeoutTriggered && System.currentTimeMillis() - offlineSince > offlineTriggerDelay) {
426 updateState(CHANNEL_MOWER_STATUS_OFFLINE_TRIGGER, new StringType("OFFLINE_TIMEOUT"));
427 offlineTimeoutTriggered = true;
429 logger.debug("Failed to communicate with the mower. Taking it offline.", rce);
430 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, rce.getMessage());
431 loadVersionInfo = true;
432 } catch (JsonSyntaxException jse) {
433 // the module sporadically sends invalid json responses. As this is usually recovered with the
434 // next poll interval, we just log it to debug here.
435 logger.debug("Failed to parse response.", jse);