]> git.basschouten.com Git - openhab-addons.git/blob
e38c3273369b224b452ba970063430e333821ffa
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.automower.internal.things;
14
15 import static org.openhab.binding.automower.internal.AutomowerBindingConstants.*;
16
17 import java.time.Instant;
18 import java.time.ZonedDateTime;
19 import java.util.ArrayList;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.Map;
23 import java.util.Optional;
24 import java.util.Set;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.atomic.AtomicReference;
28
29 import javax.measure.quantity.Dimensionless;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.automower.internal.AutomowerBindingConstants;
34 import org.openhab.binding.automower.internal.actions.AutomowerActions;
35 import org.openhab.binding.automower.internal.bridge.AutomowerBridge;
36 import org.openhab.binding.automower.internal.bridge.AutomowerBridgeHandler;
37 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Mower;
38 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Position;
39 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.RestrictedReason;
40 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.State;
41 import org.openhab.binding.automower.internal.rest.exceptions.AutomowerCommunicationException;
42 import org.openhab.core.i18n.TimeZoneProvider;
43 import org.openhab.core.library.types.DateTimeType;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.PointType;
46 import org.openhab.core.library.types.QuantityType;
47 import org.openhab.core.library.types.StringType;
48 import org.openhab.core.library.unit.Units;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.ThingTypeUID;
55 import org.openhab.core.thing.binding.BaseThingHandler;
56 import org.openhab.core.thing.binding.ThingHandler;
57 import org.openhab.core.thing.binding.ThingHandlerService;
58 import org.openhab.core.types.Command;
59 import org.openhab.core.types.RefreshType;
60 import org.openhab.core.types.Type;
61 import org.openhab.core.types.UnDefType;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
64
65 import com.google.gson.Gson;
66
67 /**
68  * The {@link AutomowerHandler} is responsible for handling commands, which are
69  * sent to one of the channels.
70  *
71  * @author Markus Pfleger - Initial contribution
72  * @author Marcin Czeczko - Added support for planner & calendar data
73  */
74 @NonNullByDefault
75 public class AutomowerHandler extends BaseThingHandler {
76     public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Collections.singleton(THING_TYPE_AUTOMOWER);
77     private static final String NO_ID = "NO_ID";
78     private static final long DEFAULT_COMMAND_DURATION_MIN = 60;
79     private static final long DEFAULT_POLLING_INTERVAL_S = TimeUnit.MINUTES.toSeconds(10);
80
81     private final Logger logger = LoggerFactory.getLogger(AutomowerHandler.class);
82     private final TimeZoneProvider timeZoneProvider;
83
84     private AtomicReference<String> automowerId = new AtomicReference<String>(NO_ID);
85     private long lastQueryTimeMs = 0L;
86
87     private @Nullable ScheduledFuture<?> automowerPollingJob;
88     private long maxQueryFrequencyNanos = TimeUnit.MINUTES.toNanos(1);
89
90     private @Nullable Mower mowerState;
91
92     private Gson gson = new Gson();
93
94     private Runnable automowerPollingRunnable = () -> {
95         Bridge bridge = getBridge();
96         if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) {
97             updateAutomowerState();
98         } else {
99             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
100         }
101     };
102
103     public AutomowerHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
104         super(thing);
105         this.timeZoneProvider = timeZoneProvider;
106     }
107
108     @Override
109     public void handleCommand(ChannelUID channelUID, Command command) {
110         if (RefreshType.REFRESH == command) {
111             logger.debug("Refreshing channel '{}'", channelUID);
112             refreshChannels(channelUID);
113         } else {
114             AutomowerCommand.fromChannelUID(channelUID).ifPresent(commandName -> {
115                 logger.debug("Sending command '{}'", commandName);
116                 getCommandValue(command).ifPresentOrElse(duration -> sendAutomowerCommand(commandName, duration),
117                         () -> sendAutomowerCommand(commandName));
118             });
119         }
120     }
121
122     private Optional<Integer> getCommandValue(Type type) {
123         if (type instanceof DecimalType) {
124             return Optional.of(((DecimalType) type).intValue());
125         }
126         return Optional.empty();
127     }
128
129     private void refreshChannels(ChannelUID channelUID) {
130         updateAutomowerState();
131     }
132
133     @Override
134     public Collection<Class<? extends ThingHandlerService>> getServices() {
135         return Collections.singleton(AutomowerActions.class);
136     }
137
138     @Override
139     public void initialize() {
140         Bridge bridge = getBridge();
141         if (bridge != null) {
142             AutomowerConfiguration currentConfig = getConfigAs(AutomowerConfiguration.class);
143             final String configMowerId = currentConfig.getMowerId();
144             final Integer pollingIntervalS = currentConfig.getPollingInterval();
145
146             if (configMowerId == null) {
147                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
148                         "@text/conf-error-no-mower-id");
149             } else if (pollingIntervalS != null && pollingIntervalS < 1) {
150                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
151                         "@text/conf-error-invalid-polling-interval");
152             } else {
153                 automowerId.set(configMowerId);
154                 startAutomowerPolling(pollingIntervalS);
155             }
156         } else {
157             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
158         }
159     }
160
161     @Nullable
162     private AutomowerBridge getAutomowerBridge() {
163         Bridge bridge = getBridge();
164         if (bridge != null) {
165             ThingHandler handler = bridge.getHandler();
166             if (handler instanceof AutomowerBridgeHandler) {
167                 AutomowerBridgeHandler bridgeHandler = (AutomowerBridgeHandler) handler;
168                 return bridgeHandler.getAutomowerBridge();
169             }
170         }
171         return null;
172     }
173
174     @Override
175     public void dispose() {
176         if (!automowerId.get().equals(NO_ID)) {
177             stopAutomowerPolling();
178             automowerId.set(NO_ID);
179         }
180     }
181
182     private void startAutomowerPolling(@Nullable Integer pollingIntervalS) {
183         if (automowerPollingJob == null) {
184             final long pollingIntervalToUse = pollingIntervalS == null ? DEFAULT_POLLING_INTERVAL_S : pollingIntervalS;
185             automowerPollingJob = scheduler.scheduleWithFixedDelay(automowerPollingRunnable, 1, pollingIntervalToUse,
186                     TimeUnit.SECONDS);
187         }
188     }
189
190     private void stopAutomowerPolling() {
191         if (automowerPollingJob != null) {
192             automowerPollingJob.cancel(true);
193             automowerPollingJob = null;
194         }
195     }
196
197     private boolean isValidResult(@Nullable Mower mower) {
198         return mower != null && mower.getAttributes() != null && mower.getAttributes().getMetadata() != null
199                 && mower.getAttributes().getBattery() != null && mower.getAttributes().getSystem() != null;
200     }
201
202     private boolean isConnected(@Nullable Mower mower) {
203         return mower != null && mower.getAttributes() != null && mower.getAttributes().getMetadata() != null
204                 && mower.getAttributes().getMetadata().isConnected();
205     }
206
207     private synchronized void updateAutomowerState() {
208         String id = automowerId.get();
209         try {
210             AutomowerBridge automowerBridge = getAutomowerBridge();
211             if (automowerBridge != null) {
212                 if (mowerState == null || (System.nanoTime() - lastQueryTimeMs > maxQueryFrequencyNanos)) {
213                     lastQueryTimeMs = System.nanoTime();
214                     mowerState = automowerBridge.getAutomowerStatus(id);
215                 }
216                 if (isValidResult(mowerState)) {
217                     initializeProperties(mowerState);
218
219                     updateChannelState(mowerState);
220
221                     if (isConnected(mowerState)) {
222                         updateStatus(ThingStatus.ONLINE);
223                     } else {
224                         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
225                                 "@text/comm-error-mower-not-connected-to-cloud");
226                     }
227                 } else {
228                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
229                             "@text/comm-error-query-mower-failed");
230                 }
231             } else {
232                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge");
233             }
234         } catch (AutomowerCommunicationException e) {
235             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
236                     "@text/comm-error-query-mower-failed");
237             logger.warn("Unable to query automower status for:  {}. Error: {}", id, e.getMessage());
238         }
239     }
240
241     /**
242      * Sends a command to the automower with the default duration of 60min
243      *
244      * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park",
245      *            "ParkUntilNextSchedule", "ParkUntilFurtherNotice"
246      */
247     public void sendAutomowerCommand(AutomowerCommand command) {
248         sendAutomowerCommand(command, DEFAULT_COMMAND_DURATION_MIN);
249     }
250
251     /**
252      * Sends a command to the automower with the given duration
253      *
254      * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park",
255      *            "ParkUntilNextSchedule", "ParkUntilFurtherNotice"
256      * @param commandDurationMinutes The duration of the command in minutes. This is only evaluated for "Start" and
257      *            "Park" commands
258      */
259     public void sendAutomowerCommand(AutomowerCommand command, long commandDurationMinutes) {
260         logger.debug("Sending command '{} {}'", command.getCommand(), commandDurationMinutes);
261         String id = automowerId.get();
262         try {
263             AutomowerBridge automowerBridge = getAutomowerBridge();
264             if (automowerBridge != null) {
265                 automowerBridge.sendAutomowerCommand(id, command, commandDurationMinutes);
266             } else {
267                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge");
268             }
269         } catch (AutomowerCommunicationException e) {
270             logger.warn("Unable to send command to automower: {}, Error: {}", id, e.getMessage());
271         }
272
273         updateAutomowerState();
274     }
275
276     private String restrictedState(RestrictedReason reason) {
277         return "RESTRICTED_" + reason.name();
278     }
279
280     private void updateChannelState(@Nullable Mower mower) {
281         if (isValidResult(mower)) {
282             updateState(CHANNEL_STATUS_NAME, new StringType(mower.getAttributes().getSystem().getName()));
283             updateState(CHANNEL_STATUS_MODE, new StringType(mower.getAttributes().getMower().getMode().name()));
284             updateState(CHANNEL_STATUS_ACTIVITY, new StringType(mower.getAttributes().getMower().getActivity().name()));
285
286             if (mower.getAttributes().getMower().getState() != State.RESTRICTED) {
287                 updateState(CHANNEL_STATUS_STATE, new StringType(mower.getAttributes().getMower().getState().name()));
288             } else {
289                 updateState(CHANNEL_STATUS_STATE,
290                         new StringType(restrictedState(mower.getAttributes().getPlanner().getRestrictedReason())));
291             }
292
293             updateState(CHANNEL_STATUS_LAST_UPDATE,
294                     new DateTimeType(toZonedDateTime(mower.getAttributes().getMetadata().getStatusTimestamp())));
295             updateState(CHANNEL_STATUS_BATTERY, new QuantityType<Dimensionless>(
296                     mower.getAttributes().getBattery().getBatteryPercent(), Units.PERCENT));
297
298             updateState(CHANNEL_STATUS_ERROR_CODE, new DecimalType(mower.getAttributes().getMower().getErrorCode()));
299
300             long errorCodeTimestamp = mower.getAttributes().getMower().getErrorCodeTimestamp();
301             if (errorCodeTimestamp == 0L) {
302                 updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, UnDefType.NULL);
303             } else {
304                 updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, new DateTimeType(toZonedDateTime(errorCodeTimestamp)));
305             }
306
307             long nextStartTimestamp = mower.getAttributes().getPlanner().getNextStartTimestamp();
308             // If next start timestamp is 0 it means the mower should start now, so using current timestamp
309             if (nextStartTimestamp == 0L) {
310                 updateState(CHANNEL_PLANNER_NEXT_START, UnDefType.NULL);
311             } else {
312                 updateState(CHANNEL_PLANNER_NEXT_START, new DateTimeType(toZonedDateTime(nextStartTimestamp)));
313             }
314             updateState(CHANNEL_PLANNER_OVERRIDE_ACTION,
315                     new StringType(mower.getAttributes().getPlanner().getOverride().getAction()));
316
317             updateState(CHANNEL_CALENDAR_TASKS,
318                     new StringType(gson.toJson(mower.getAttributes().getCalendar().getTasks())));
319
320             updateState(LAST_POSITION,
321                     new PointType(new DecimalType(mower.getAttributes().getLastPosition().getLatitude()),
322                             new DecimalType(mower.getAttributes().getLastPosition().getLongitude())));
323             ArrayList<Position> positions = mower.getAttributes().getPositions();
324             for (int i = 0; i < positions.size(); i++) {
325                 updateState(CHANNEL_POSITIONS.get(i), new PointType(new DecimalType(positions.get(i).getLatitude()),
326                         new DecimalType(positions.get(i).getLongitude())));
327
328             }
329         }
330     }
331
332     private void initializeProperties(@Nullable Mower mower) {
333         Map<String, String> properties = editProperties();
334
335         properties.put(AutomowerBindingConstants.AUTOMOWER_ID, mower.getId());
336
337         if (mower.getAttributes() != null && mower.getAttributes().getSystem() != null) {
338             properties.put(AutomowerBindingConstants.AUTOMOWER_SERIAL_NUMBER,
339                     mower.getAttributes().getSystem().getSerialNumber());
340             properties.put(AutomowerBindingConstants.AUTOMOWER_MODEL, mower.getAttributes().getSystem().getModel());
341             properties.put(AutomowerBindingConstants.AUTOMOWER_NAME, mower.getAttributes().getSystem().getName());
342         }
343
344         updateProperties(properties);
345     }
346
347     /**
348      * Converts timestamp returned by the Automower API into local time-zone.
349      * Timestamp returned by the API doesn't have offset and it always in the current time zone - it can be treated as
350      * UTC.
351      * Method builds a ZonedDateTime with same hour value but in the current system timezone.
352      *
353      * @param timestamp - Automower API timestamp
354      * @return ZonedDateTime in system timezone
355      */
356     private ZonedDateTime toZonedDateTime(long timestamp) {
357         Instant timestampInstant = Instant.ofEpochMilli(timestamp);
358         return ZonedDateTime.ofInstant(timestampInstant, timeZoneProvider.getTimeZone());
359     }
360 }