]> git.basschouten.com Git - openhab-addons.git/blob
36618ffb542db4331ef2abeeab9111ecd79ef8c2
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.robonect.internal.handler;
14
15 import static org.openhab.binding.robonect.internal.RobonectBindingConstants.*;
16
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;
23 import java.util.Map;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26
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;
61
62 import com.google.gson.JsonSyntaxException;
63
64 /**
65  * The {@link RobonectHandler} is responsible for handling commands, which are
66  * sent to one of the channels.
67  *
68  * The channels are periodically updated by polling the mower via HTTP in a separate thread.
69  *
70  * @author Marco Meyer - Initial contribution
71  */
72 public class RobonectHandler extends BaseThingHandler {
73
74     private final Logger logger = LoggerFactory.getLogger(RobonectHandler.class);
75
76     private ScheduledFuture<?> pollingJob;
77
78     private HttpClient httpClient;
79     private TimeZoneProvider timeZoneProvider;
80
81     private ZoneId timeZone;
82
83     private RobonectClient robonectClient;
84
85     public RobonectHandler(Thing thing, HttpClientFactory httpClientFactory, TimeZoneProvider timeZoneProvider) {
86         super(thing);
87         httpClient = httpClientFactory
88                 .createHttpClient(ThingWebClientUtil.buildWebClientConsumerName(thing.getUID(), null));
89         this.timeZoneProvider = timeZoneProvider;
90     }
91
92     @Override
93     public void handleCommand(ChannelUID channelUID, Command command) {
94         try {
95             if (command instanceof RefreshType) {
96                 refreshChannels(channelUID);
97             } else {
98                 sendCommand(channelUID, command);
99             }
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());
104         }
105     }
106
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);
112                 } else {
113                     logger.debug("Got name update of type {} but StringType is expected.",
114                             command.getClass().getName());
115                 }
116                 break;
117
118             case CHANNEL_STATUS_MODE:
119                 if (command instanceof StringType) {
120                     setMowerMode(command);
121                 } else {
122                     logger.debug("Got job remote start update of type {} but StringType is expected.",
123                             command.getClass().getName());
124                 }
125                 break;
126
127             case CHANNEL_MOWER_START:
128                 if (command instanceof OnOffType onOffCommand) {
129                     handleStartStop(onOffCommand);
130                 } else {
131                     logger.debug("Got stopped update of type {} but OnOffType is expected.",
132                             command.getClass().getName());
133                 }
134                 break;
135
136             case CHANNEL_JOB:
137                 if (command instanceof OnOffType) {
138                     handleJobCommand(channelUID, command);
139                 } else {
140                     logger.debug("Got job update of type {} but OnOffType is expected.", command.getClass().getName());
141                 }
142                 break;
143         }
144     }
145
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())));
157         }
158     }
159
160     private void refreshChannels(ChannelUID channelUID) {
161         switch (channelUID.getId()) {
162             case CHANNEL_MOWER_NAME:
163             case CHANNEL_STATUS_BATTERY:
164             case CHANNEL_STATUS:
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:
172             case CHANNEL_JOB:
173                 refreshMowerInfo();
174                 break;
175             default:
176             case CHANNEL_LAST_ERROR_CODE:
177             case CHANNEL_LAST_ERROR_DATE:
178             case CHANNEL_LAST_ERROR_MESSAGE:
179                 refreshLastErrorInfo();
180                 break;
181         }
182     }
183
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()));
189         } else {
190             refreshMowerInfo();
191         }
192     }
193
194     private void logErrorFromResponse(RobonectAnswer result) {
195         if (!result.isSuccessful()) {
196             logger.debug("Could not send EOD Trigger. Robonect error message: {}", result.getErrorMessage());
197         }
198     }
199
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();
207         }
208         if (answer != null) {
209             if (answer.isSuccessful()) {
210                 updateState(CHANNEL_MOWER_START, command);
211             } else {
212                 logErrorFromResponse(answer);
213                 refreshMowerInfo();
214             }
215         }
216     }
217
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()));
223         } else {
224             logErrorFromResponse(name);
225             refreshMowerInfo();
226         }
227     }
228
229     private void refreshMowerInfo() {
230         MowerInfo info = robonectClient.getMowerInfo();
231         if (info.isSuccessful()) {
232             if (info.getError() != null) {
233                 updateErrorInfo(info.getError());
234                 refreshLastErrorInfo();
235             } else {
236                 clearErrorInfo();
237             }
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));
250             }
251             if (info.getTimer() != null) {
252                 if (info.getTimer().getNext() != null) {
253                     updateNextTimer(info);
254                 }
255                 updateState(CHANNEL_TIMER_STATUS, new StringType(info.getTimer().getStatus().name()));
256             }
257             updateState(CHANNEL_WLAN_SIGNAL, new DecimalType(info.getWlan().getSignal()));
258             updateState(CHANNEL_JOB, OnOffType.from(robonectClient.isJobRunning()));
259         } else {
260             logger.error("Could not retrieve mower info. Robonect error response message: {}", info.getErrorMessage());
261         }
262     }
263
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);
268     }
269
270     private void updateErrorInfo(ErrorEntry error) {
271         if (error.getErrorMessage() != null) {
272             updateState(CHANNEL_ERROR_MESSAGE, new StringType(error.getErrorMessage()));
273         }
274         if (error.getErrorCode() != null) {
275             updateState(CHANNEL_ERROR_CODE, new DecimalType(error.getErrorCode().intValue()));
276         }
277         if (error.getDate() != null) {
278             State dateTime = convertUnixToDateTimeType(error.getUnix());
279             updateState(CHANNEL_ERROR_DATE, dateTime);
280         }
281     }
282
283     private void updateNextTimer(MowerInfo info) {
284         State dateTime = convertUnixToDateTimeType(info.getTimer().getNext().getUnix());
285         updateState(CHANNEL_TIMER_NEXT_TIMER, dateTime);
286     }
287
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);
293
294         ZoneId timeZoneOfThing = timeZone;
295         if (timeZoneOfThing == null) {
296             timeZoneOfThing = timeZoneProvider.getTimeZone();
297         }
298         ZoneOffset offsetToConfiguredZone = timeZoneOfThing.getRules().getOffset(rawInstant);
299         long adjustedTime = rawInstant.getEpochSecond() - offsetToConfiguredZone.getTotalSeconds();
300         Instant adjustedInstant = Instant.ofEpochMilli(adjustedTime * 1000);
301
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);
305     }
306
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);
316         } else {
317             logger.debug("Could not retrieve mower version info. Robonect error response message: {}",
318                     info.getErrorMessage());
319         }
320     }
321
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);
329             }
330         } else {
331             logger.debug("Could not retrieve mower error list. Robonect error response message: {}",
332                     errorList.getErrorMessage());
333         }
334     }
335
336     private void updateLastErrorChannels(ErrorEntry error) {
337         if (error.getErrorMessage() != null) {
338             updateState(CHANNEL_LAST_ERROR_MESSAGE, new StringType(error.getErrorMessage()));
339         }
340         if (error.getErrorCode() != null) {
341             updateState(CHANNEL_LAST_ERROR_CODE, new DecimalType(error.getErrorCode().intValue()));
342         }
343         if (error.getDate() != null) {
344             State dateTime = convertUnixToDateTimeType(error.getUnix());
345             updateState(CHANNEL_LAST_ERROR_DATE, dateTime);
346         }
347     }
348
349     @Override
350     public void initialize() {
351         RobonectConfig robonectConfig = getConfigAs(RobonectConfig.class);
352         RobonectEndpoint endpoint = new RobonectEndpoint(robonectConfig.getHost(), robonectConfig.getUser(),
353                 robonectConfig.getPassword());
354
355         String timeZoneString = robonectConfig.getTimezone();
356         try {
357             if (timeZoneString != null) {
358                 timeZone = ZoneId.of(timeZoneString);
359             } else {
360                 logger.warn("No timezone provided, falling back to the default timezone configured in openHAB: '{}'",
361                         timeZoneProvider.getTimeZone());
362             }
363         } catch (DateTimeException e) {
364             logger.warn("Error setting timezone '{}', falling back to the default timezone configured in openHAB: '{}'",
365                     timeZoneString, timeZoneProvider.getTimeZone(), e);
366         }
367
368         try {
369             httpClient.start();
370         } catch (Exception e) {
371             logger.error("Exception while trying to start HTTP client", e);
372             throw new IllegalStateException("Could not create HttpClient");
373         }
374
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);
379     }
380
381     @Override
382     public void dispose() {
383         ScheduledFuture<?> pollingJob = this.pollingJob;
384         if (pollingJob != null) {
385             pollingJob.cancel(true);
386             this.pollingJob = null;
387         }
388
389         try {
390             httpClient.stop();
391         } catch (Exception e) {
392             logger.warn("Exception while trying to stop HTTP client", e);
393         }
394     }
395
396     /**
397      * method to inject the robonect client to be used in test cases to allow mocking.
398      *
399      * @param robonectClient
400      */
401     protected void setRobonectClient(RobonectClient robonectClient) {
402         this.robonectClient = robonectClient;
403     }
404
405     private class MowerChannelPoller implements Runnable {
406
407         private long offlineSince;
408         private long offlineTriggerDelay;
409         private boolean offlineTimeoutTriggered;
410         private boolean loadVersionInfo = true;
411
412         public MowerChannelPoller(long offlineTriggerDelay) {
413             offlineSince = -1;
414             this.offlineTriggerDelay = offlineTriggerDelay;
415             offlineTimeoutTriggered = false;
416         }
417
418         @Override
419         public void run() {
420             try {
421                 if (loadVersionInfo) {
422                     refreshVersionInfo();
423                     loadVersionInfo = false;
424                 }
425                 refreshMowerInfo();
426                 updateStatus(ThingStatus.ONLINE);
427                 offlineSince = -1;
428                 offlineTimeoutTriggered = false;
429             } catch (RobonectCommunicationException rce) {
430                 if (offlineSince < 0) {
431                     offlineSince = System.currentTimeMillis();
432                 }
433                 if (!offlineTimeoutTriggered && System.currentTimeMillis() - offlineSince > offlineTriggerDelay) {
434                     // trigger offline
435                     updateState(CHANNEL_MOWER_STATUS_OFFLINE_TRIGGER, new StringType("OFFLINE_TIMEOUT"));
436                     offlineTimeoutTriggered = true;
437                 }
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);
445             }
446         }
447     }
448 }