2 * Copyright (c) 2010-2024 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.io.net.http.HttpClientFactory;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.OnOffType;
45 import org.openhab.core.library.types.QuantityType;
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.thing.util.ThingWebClientUtil;
55 import org.openhab.core.types.Command;
56 import org.openhab.core.types.RefreshType;
57 import org.openhab.core.types.State;
58 import org.openhab.core.types.UnDefType;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import com.google.gson.JsonSyntaxException;
65 * The {@link RobonectHandler} is responsible for handling commands, which are
66 * sent to one of the channels.
68 * The channels are periodically updated by polling the mower via HTTP in a separate thread.
70 * @author Marco Meyer - Initial contribution
72 public class RobonectHandler extends BaseThingHandler {
74 private final Logger logger = LoggerFactory.getLogger(RobonectHandler.class);
76 private ScheduledFuture<?> pollingJob;
78 private HttpClient httpClient;
79 private TimeZoneProvider timeZoneProvider;
81 private ZoneId timeZone;
83 private RobonectClient robonectClient;
85 public RobonectHandler(Thing thing, HttpClientFactory httpClientFactory, TimeZoneProvider timeZoneProvider) {
87 httpClient = httpClientFactory
88 .createHttpClient(ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null));
89 this.timeZoneProvider = timeZoneProvider;
93 public void handleCommand(ChannelUID channelUID, Command command) {
95 if (command instanceof RefreshType) {
96 refreshChannels(channelUID);
98 sendCommand(channelUID, command);
100 updateStatus(ThingStatus.ONLINE);
101 } catch (RobonectCommunicationException rce) {
102 logger.debug("Failed to communicate with the mower. Taking it offline.", rce);
103 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, rce.getMessage());
107 private void sendCommand(ChannelUID channelUID, Command command) {
108 switch (channelUID.getId()) {
109 case CHANNEL_MOWER_NAME:
110 if (command instanceof StringType stringCommand) {
111 updateName(stringCommand);
113 logger.debug("Got name update of type {} but StringType is expected.",
114 command.getClass().getName());
118 case CHANNEL_STATUS_MODE:
119 if (command instanceof StringType) {
120 setMowerMode(command);
122 logger.debug("Got job remote start update of type {} but StringType is expected.",
123 command.getClass().getName());
127 case CHANNEL_MOWER_START:
128 if (command instanceof OnOffType onOffCommand) {
129 handleStartStop(onOffCommand);
131 logger.debug("Got stopped update of type {} but OnOffType is expected.",
132 command.getClass().getName());
137 if (command instanceof OnOffType) {
138 handleJobCommand(channelUID, command);
140 logger.debug("Got job update of type {} but OnOffType is expected.", command.getClass().getName());
146 private void handleJobCommand(ChannelUID channelUID, Command command) {
147 JobChannelConfig jobConfig = getThing().getChannel(channelUID.getId()).getConfiguration()
148 .as(JobChannelConfig.class);
149 if (command == OnOffType.ON) {
150 robonectClient.startJob(
151 new RobonectClient.JobSettings().withAfterMode(ModeCommand.Mode.valueOf(jobConfig.getAfterMode()))
152 .withRemoteStart(ModeCommand.RemoteStart.valueOf(jobConfig.getRemoteStart()))
153 .withDuration(jobConfig.getDuration()));
154 } else if (command == OnOffType.OFF) {
155 robonectClient.stopJob(
156 new RobonectClient.JobSettings().withAfterMode(ModeCommand.Mode.valueOf(jobConfig.getAfterMode())));
160 private void refreshChannels(ChannelUID channelUID) {
161 switch (channelUID.getId()) {
162 case CHANNEL_MOWER_NAME:
163 case CHANNEL_STATUS_BATTERY:
165 case CHANNEL_STATUS_DURATION:
166 case CHANNEL_STATUS_HOURS:
167 case CHANNEL_STATUS_MODE:
168 case CHANNEL_MOWER_START:
169 case CHANNEL_TIMER_NEXT_TIMER:
170 case CHANNEL_TIMER_STATUS:
171 case CHANNEL_WLAN_SIGNAL:
176 case CHANNEL_LAST_ERROR_CODE:
177 case CHANNEL_LAST_ERROR_DATE:
178 case CHANNEL_LAST_ERROR_MESSAGE:
179 refreshLastErrorInfo();
184 private void setMowerMode(Command command) {
185 String modeStr = command.toFullString();
186 ModeCommand.Mode newMode = ModeCommand.Mode.valueOf(modeStr.toUpperCase());
187 if (robonectClient.setMode(newMode).isSuccessful()) {
188 updateState(CHANNEL_STATUS_MODE, new StringType(newMode.name()));
194 private void logErrorFromResponse(RobonectAnswer result) {
195 if (!result.isSuccessful()) {
196 logger.debug("Could not send EOD Trigger. Robonect error message: {}", result.getErrorMessage());
200 private void handleStartStop(final OnOffType command) {
201 RobonectAnswer answer = null;
202 boolean currentlyStopped = robonectClient.getMowerInfo().getStatus().isStopped();
203 if (command == OnOffType.ON && currentlyStopped) {
204 answer = robonectClient.start();
205 } else if (command == OnOffType.OFF && !currentlyStopped) {
206 answer = robonectClient.stop();
208 if (answer != null) {
209 if (answer.isSuccessful()) {
210 updateState(CHANNEL_MOWER_START, command);
212 logErrorFromResponse(answer);
218 private void updateName(StringType command) {
219 String newName = command.toFullString();
220 Name name = robonectClient.setName(newName);
221 if (name.isSuccessful()) {
222 updateState(CHANNEL_MOWER_NAME, new StringType(name.getName()));
224 logErrorFromResponse(name);
229 private void refreshMowerInfo() {
230 MowerInfo info = robonectClient.getMowerInfo();
231 if (info.isSuccessful()) {
232 if (info.getError() != null) {
233 updateErrorInfo(info.getError());
234 refreshLastErrorInfo();
238 updateState(CHANNEL_MOWER_NAME, new StringType(info.getName()));
239 updateState(CHANNEL_STATUS_BATTERY, new DecimalType(info.getStatus().getBattery()));
240 updateState(CHANNEL_STATUS, new DecimalType(info.getStatus().getStatus().getStatusCode()));
241 updateState(CHANNEL_STATUS_DURATION, new QuantityType<>(info.getStatus().getDuration(), Units.SECOND));
242 updateState(CHANNEL_STATUS_DISTANCE, new QuantityType<>(info.getStatus().getDistance(), SIUnits.METRE));
243 updateState(CHANNEL_STATUS_HOURS, new QuantityType<>(info.getStatus().getHours(), Units.HOUR));
244 updateState(CHANNEL_STATUS_MODE, new StringType(info.getStatus().getMode().name()));
245 updateState(CHANNEL_MOWER_START, OnOffType.from(!info.getStatus().isStopped()));
246 if (info.getHealth() != null) {
247 updateState(CHANNEL_HEALTH_TEMP,
248 new QuantityType<>(info.getHealth().getTemperature(), SIUnits.CELSIUS));
249 updateState(CHANNEL_HEALTH_HUM, new QuantityType<>(info.getHealth().getHumidity(), Units.PERCENT));
251 if (info.getTimer() != null) {
252 if (info.getTimer().getNext() != null) {
253 updateNextTimer(info);
255 updateState(CHANNEL_TIMER_STATUS, new StringType(info.getTimer().getStatus().name()));
257 updateState(CHANNEL_WLAN_SIGNAL, new DecimalType(info.getWlan().getSignal()));
258 updateState(CHANNEL_JOB, OnOffType.from(robonectClient.isJobRunning()));
260 logger.error("Could not retrieve mower info. Robonect error response message: {}", info.getErrorMessage());
264 private void clearErrorInfo() {
265 updateState(CHANNEL_ERROR_DATE, UnDefType.UNDEF);
266 updateState(CHANNEL_ERROR_CODE, UnDefType.UNDEF);
267 updateState(CHANNEL_ERROR_MESSAGE, UnDefType.UNDEF);
270 private void updateErrorInfo(ErrorEntry error) {
271 if (error.getErrorMessage() != null) {
272 updateState(CHANNEL_ERROR_MESSAGE, new StringType(error.getErrorMessage()));
274 if (error.getErrorCode() != null) {
275 updateState(CHANNEL_ERROR_CODE, new DecimalType(error.getErrorCode().intValue()));
277 if (error.getDate() != null) {
278 State dateTime = convertUnixToDateTimeType(error.getUnix());
279 updateState(CHANNEL_ERROR_DATE, dateTime);
283 private void updateNextTimer(MowerInfo info) {
284 State dateTime = convertUnixToDateTimeType(info.getTimer().getNext().getUnix());
285 updateState(CHANNEL_TIMER_NEXT_TIMER, dateTime);
288 private State convertUnixToDateTimeType(String unixTimeSec) {
289 // the value in unixTimeSec represents the time on the robot in its configured timezone. However, it does not
290 // provide which zone this is. Thus we have to add the zone information from the Thing configuration in order to
291 // provide correct results.
292 Instant rawInstant = Instant.ofEpochMilli(Long.valueOf(unixTimeSec) * 1000);
294 ZoneId timeZoneOfThing = timeZone;
295 if (timeZoneOfThing == null) {
296 timeZoneOfThing = timeZoneProvider.getTimeZone();
298 ZoneOffset offsetToConfiguredZone = timeZoneOfThing.getRules().getOffset(rawInstant);
299 long adjustedTime = rawInstant.getEpochSecond() - offsetToConfiguredZone.getTotalSeconds();
300 Instant adjustedInstant = Instant.ofEpochMilli(adjustedTime * 1000);
302 // we provide the time in the format as configured in the openHAB settings
303 ZonedDateTime zdt = adjustedInstant.atZone(timeZoneProvider.getTimeZone());
304 return new DateTimeType(zdt);
307 private void refreshVersionInfo() {
308 VersionInfo info = robonectClient.getVersionInfo();
309 if (info.isSuccessful()) {
310 Map<String, String> properties = editProperties();
311 properties.put(Thing.PROPERTY_SERIAL_NUMBER, info.getRobonect().getSerial());
312 properties.put(Thing.PROPERTY_FIRMWARE_VERSION, info.getRobonect().getVersion());
313 properties.put(PROPERTY_COMPILED, info.getRobonect().getCompiled());
314 properties.put(PROPERTY_COMMENT, info.getRobonect().getComment());
315 updateProperties(properties);
317 logger.debug("Could not retrieve mower version info. Robonect error response message: {}",
318 info.getErrorMessage());
322 private void refreshLastErrorInfo() {
323 ErrorList errorList = robonectClient.errorList();
324 if (errorList.isSuccessful()) {
325 List<ErrorEntry> errors = errorList.getErrors();
326 if (errors != null && !errors.isEmpty()) {
327 ErrorEntry lastErrorEntry = errors.get(0);
328 updateLastErrorChannels(lastErrorEntry);
331 logger.debug("Could not retrieve mower error list. Robonect error response message: {}",
332 errorList.getErrorMessage());
336 private void updateLastErrorChannels(ErrorEntry error) {
337 if (error.getErrorMessage() != null) {
338 updateState(CHANNEL_LAST_ERROR_MESSAGE, new StringType(error.getErrorMessage()));
340 if (error.getErrorCode() != null) {
341 updateState(CHANNEL_LAST_ERROR_CODE, new DecimalType(error.getErrorCode().intValue()));
343 if (error.getDate() != null) {
344 State dateTime = convertUnixToDateTimeType(error.getUnix());
345 updateState(CHANNEL_LAST_ERROR_DATE, dateTime);
350 public void initialize() {
351 RobonectConfig robonectConfig = getConfigAs(RobonectConfig.class);
352 RobonectEndpoint endpoint = new RobonectEndpoint(robonectConfig.getHost(), robonectConfig.getUser(),
353 robonectConfig.getPassword());
355 String timeZoneString = robonectConfig.getTimezone();
357 if (timeZoneString != null) {
358 timeZone = ZoneId.of(timeZoneString);
360 logger.warn("No timezone provided, falling back to the default timezone configured in openHAB: '{}'",
361 timeZoneProvider.getTimeZone());
363 } catch (DateTimeException e) {
364 logger.warn("Error setting timezone '{}', falling back to the default timezone configured in openHAB: '{}'",
365 timeZoneString, timeZoneProvider.getTimeZone(), e);
370 } catch (Exception e) {
371 logger.error("Exception while trying to start HTTP client", e);
372 throw new IllegalStateException("Could not create HttpClient");
375 robonectClient = new RobonectClient(httpClient, endpoint);
376 Runnable runnable = new MowerChannelPoller(TimeUnit.SECONDS.toMillis(robonectConfig.getOfflineTimeout()));
377 int pollInterval = robonectConfig.getPollInterval();
378 pollingJob = scheduler.scheduleWithFixedDelay(runnable, 0, pollInterval, TimeUnit.SECONDS);
382 public void dispose() {
383 ScheduledFuture<?> pollingJob = this.pollingJob;
384 if (pollingJob != null) {
385 pollingJob.cancel(true);
386 this.pollingJob = null;
391 } catch (Exception e) {
392 logger.warn("Exception while trying to stop HTTP client", e);
397 * method to inject the robonect client to be used in test cases to allow mocking.
399 * @param robonectClient
401 protected void setRobonectClient(RobonectClient robonectClient) {
402 this.robonectClient = robonectClient;
405 private class MowerChannelPoller implements Runnable {
407 private long offlineSince;
408 private long offlineTriggerDelay;
409 private boolean offlineTimeoutTriggered;
410 private boolean loadVersionInfo = true;
412 public MowerChannelPoller(long offlineTriggerDelay) {
414 this.offlineTriggerDelay = offlineTriggerDelay;
415 offlineTimeoutTriggered = false;
421 if (loadVersionInfo) {
422 refreshVersionInfo();
423 loadVersionInfo = false;
426 updateStatus(ThingStatus.ONLINE);
428 offlineTimeoutTriggered = false;
429 } catch (RobonectCommunicationException rce) {
430 if (offlineSince < 0) {
431 offlineSince = System.currentTimeMillis();
433 if (!offlineTimeoutTriggered && System.currentTimeMillis() - offlineSince > offlineTriggerDelay) {
435 updateState(CHANNEL_MOWER_STATUS_OFFLINE_TRIGGER, new StringType("OFFLINE_TIMEOUT"));
436 offlineTimeoutTriggered = true;
438 logger.debug("Failed to communicate with the mower. Taking it offline.", rce);
439 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, rce.getMessage());
440 loadVersionInfo = true;
441 } catch (JsonSyntaxException jse) {
442 // the module sporadically sends invalid json responses. As this is usually recovered with the
443 // next poll interval, we just log it to debug here.
444 logger.debug("Failed to parse response.", jse);