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.automower.internal.things;
15 import static org.openhab.binding.automower.internal.AutomowerBindingConstants.*;
17 import java.time.Instant;
18 import java.time.ZonedDateTime;
19 import java.util.ArrayList;
20 import java.util.Collection;
22 import java.util.Optional;
24 import java.util.concurrent.ScheduledFuture;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.atomic.AtomicReference;
28 import javax.measure.quantity.Dimensionless;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.automower.internal.AutomowerBindingConstants;
33 import org.openhab.binding.automower.internal.actions.AutomowerActions;
34 import org.openhab.binding.automower.internal.bridge.AutomowerBridge;
35 import org.openhab.binding.automower.internal.bridge.AutomowerBridgeHandler;
36 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Mower;
37 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.Position;
38 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.RestrictedReason;
39 import org.openhab.binding.automower.internal.rest.api.automowerconnect.dto.State;
40 import org.openhab.binding.automower.internal.rest.exceptions.AutomowerCommunicationException;
41 import org.openhab.core.i18n.TimeZoneProvider;
42 import org.openhab.core.library.types.DateTimeType;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.PointType;
45 import org.openhab.core.library.types.QuantityType;
46 import org.openhab.core.library.types.StringType;
47 import org.openhab.core.library.unit.Units;
48 import org.openhab.core.thing.Bridge;
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.ThingTypeUID;
54 import org.openhab.core.thing.binding.BaseThingHandler;
55 import org.openhab.core.thing.binding.ThingHandler;
56 import org.openhab.core.thing.binding.ThingHandlerService;
57 import org.openhab.core.types.Command;
58 import org.openhab.core.types.RefreshType;
59 import org.openhab.core.types.Type;
60 import org.openhab.core.types.UnDefType;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
64 import com.google.gson.Gson;
67 * The {@link AutomowerHandler} is responsible for handling commands, which are
68 * sent to one of the channels.
70 * @author Markus Pfleger - Initial contribution
71 * @author Marcin Czeczko - Added support for planner and calendar data
74 public class AutomowerHandler extends BaseThingHandler {
75 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(THING_TYPE_AUTOMOWER);
76 private static final String NO_ID = "NO_ID";
77 private static final long DEFAULT_COMMAND_DURATION_MIN = 60;
78 private static final long DEFAULT_POLLING_INTERVAL_S = TimeUnit.MINUTES.toSeconds(10);
80 private final Logger logger = LoggerFactory.getLogger(AutomowerHandler.class);
81 private final TimeZoneProvider timeZoneProvider;
83 private AtomicReference<String> automowerId = new AtomicReference<String>(NO_ID);
84 private long lastQueryTimeMs = 0L;
86 private @Nullable ScheduledFuture<?> automowerPollingJob;
87 private long maxQueryFrequencyNanos = TimeUnit.MINUTES.toNanos(1);
89 private @Nullable Mower mowerState;
91 private Gson gson = new Gson();
93 private Runnable automowerPollingRunnable = () -> {
94 Bridge bridge = getBridge();
95 if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) {
96 updateAutomowerState();
98 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
102 public AutomowerHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
104 this.timeZoneProvider = timeZoneProvider;
108 public void handleCommand(ChannelUID channelUID, Command command) {
109 if (RefreshType.REFRESH == command) {
110 logger.debug("Refreshing channel '{}'", channelUID);
111 refreshChannels(channelUID);
113 AutomowerCommand.fromChannelUID(channelUID).ifPresent(commandName -> {
114 logger.debug("Sending command '{}'", commandName);
115 getCommandValue(command).ifPresentOrElse(duration -> sendAutomowerCommand(commandName, duration),
116 () -> sendAutomowerCommand(commandName));
121 private Optional<Integer> getCommandValue(Type type) {
122 if (type instanceof DecimalType command) {
123 return Optional.of(command.intValue());
125 return Optional.empty();
128 private void refreshChannels(ChannelUID channelUID) {
129 updateAutomowerState();
133 public Collection<Class<? extends ThingHandlerService>> getServices() {
134 return Set.of(AutomowerActions.class);
138 public void initialize() {
139 Bridge bridge = getBridge();
140 if (bridge != null) {
141 AutomowerConfiguration currentConfig = getConfigAs(AutomowerConfiguration.class);
142 final String configMowerId = currentConfig.getMowerId();
143 final Integer pollingIntervalS = currentConfig.getPollingInterval();
145 if (configMowerId == null) {
146 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
147 "@text/conf-error-no-mower-id");
148 } else if (pollingIntervalS != null && pollingIntervalS < 1) {
149 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
150 "@text/conf-error-invalid-polling-interval");
152 automowerId.set(configMowerId);
153 startAutomowerPolling(pollingIntervalS);
156 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
161 private AutomowerBridge getAutomowerBridge() {
162 Bridge bridge = getBridge();
163 if (bridge != null) {
164 ThingHandler handler = bridge.getHandler();
165 if (handler instanceof AutomowerBridgeHandler bridgeHandler) {
166 return bridgeHandler.getAutomowerBridge();
173 public void dispose() {
174 if (!automowerId.get().equals(NO_ID)) {
175 stopAutomowerPolling();
176 automowerId.set(NO_ID);
180 private void startAutomowerPolling(@Nullable Integer pollingIntervalS) {
181 if (automowerPollingJob == null) {
182 final long pollingIntervalToUse = pollingIntervalS == null ? DEFAULT_POLLING_INTERVAL_S : pollingIntervalS;
183 automowerPollingJob = scheduler.scheduleWithFixedDelay(automowerPollingRunnable, 1, pollingIntervalToUse,
188 private void stopAutomowerPolling() {
189 if (automowerPollingJob != null) {
190 automowerPollingJob.cancel(true);
191 automowerPollingJob = null;
195 private boolean isValidResult(@Nullable Mower mower) {
196 return mower != null && mower.getAttributes() != null && mower.getAttributes().getMetadata() != null
197 && mower.getAttributes().getBattery() != null && mower.getAttributes().getSystem() != null;
200 private boolean isConnected(@Nullable Mower mower) {
201 return mower != null && mower.getAttributes() != null && mower.getAttributes().getMetadata() != null
202 && mower.getAttributes().getMetadata().isConnected();
205 private synchronized void updateAutomowerState() {
206 String id = automowerId.get();
208 AutomowerBridge automowerBridge = getAutomowerBridge();
209 if (automowerBridge != null) {
210 if (mowerState == null || (System.nanoTime() - lastQueryTimeMs > maxQueryFrequencyNanos)) {
211 lastQueryTimeMs = System.nanoTime();
212 mowerState = automowerBridge.getAutomowerStatus(id);
214 if (isValidResult(mowerState)) {
215 initializeProperties(mowerState);
217 updateChannelState(mowerState);
219 if (isConnected(mowerState)) {
220 updateStatus(ThingStatus.ONLINE);
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
223 "@text/comm-error-mower-not-connected-to-cloud");
226 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
227 "@text/comm-error-query-mower-failed");
230 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge");
232 } catch (AutomowerCommunicationException e) {
233 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
234 "@text/comm-error-query-mower-failed");
235 logger.warn("Unable to query automower status for: {}. Error: {}", id, e.getMessage());
240 * Sends a command to the automower with the default duration of 60min
242 * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park",
243 * "ParkUntilNextSchedule", "ParkUntilFurtherNotice"
245 public void sendAutomowerCommand(AutomowerCommand command) {
246 sendAutomowerCommand(command, DEFAULT_COMMAND_DURATION_MIN);
250 * Sends a command to the automower with the given duration
252 * @param command The command that should be sent. Valid values are: "Start", "ResumeSchedule", "Pause", "Park",
253 * "ParkUntilNextSchedule", "ParkUntilFurtherNotice"
254 * @param commandDurationMinutes The duration of the command in minutes. This is only evaluated for "Start" and
257 public void sendAutomowerCommand(AutomowerCommand command, long commandDurationMinutes) {
258 logger.debug("Sending command '{} {}'", command.getCommand(), commandDurationMinutes);
259 String id = automowerId.get();
261 AutomowerBridge automowerBridge = getAutomowerBridge();
262 if (automowerBridge != null) {
263 automowerBridge.sendAutomowerCommand(id, command, commandDurationMinutes);
265 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "@text/conf-error-no-bridge");
267 } catch (AutomowerCommunicationException e) {
268 logger.warn("Unable to send command to automower: {}, Error: {}", id, e.getMessage());
271 updateAutomowerState();
274 private String restrictedState(RestrictedReason reason) {
275 return "RESTRICTED_" + reason.name();
278 private void updateChannelState(@Nullable Mower mower) {
279 if (isValidResult(mower)) {
280 updateState(CHANNEL_STATUS_NAME, new StringType(mower.getAttributes().getSystem().getName()));
281 updateState(CHANNEL_STATUS_MODE, new StringType(mower.getAttributes().getMower().getMode().name()));
282 updateState(CHANNEL_STATUS_ACTIVITY, new StringType(mower.getAttributes().getMower().getActivity().name()));
284 if (mower.getAttributes().getMower().getState() != State.RESTRICTED) {
285 updateState(CHANNEL_STATUS_STATE, new StringType(mower.getAttributes().getMower().getState().name()));
287 updateState(CHANNEL_STATUS_STATE,
288 new StringType(restrictedState(mower.getAttributes().getPlanner().getRestrictedReason())));
291 updateState(CHANNEL_STATUS_LAST_UPDATE,
292 new DateTimeType(toZonedDateTime(mower.getAttributes().getMetadata().getStatusTimestamp())));
293 updateState(CHANNEL_STATUS_BATTERY, new QuantityType<Dimensionless>(
294 mower.getAttributes().getBattery().getBatteryPercent(), Units.PERCENT));
296 updateState(CHANNEL_STATUS_ERROR_CODE, new DecimalType(mower.getAttributes().getMower().getErrorCode()));
298 long errorCodeTimestamp = mower.getAttributes().getMower().getErrorCodeTimestamp();
299 if (errorCodeTimestamp == 0L) {
300 updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, UnDefType.NULL);
302 updateState(CHANNEL_STATUS_ERROR_TIMESTAMP, new DateTimeType(toZonedDateTime(errorCodeTimestamp)));
305 long nextStartTimestamp = mower.getAttributes().getPlanner().getNextStartTimestamp();
306 // If next start timestamp is 0 it means the mower should start now, so using current timestamp
307 if (nextStartTimestamp == 0L) {
308 updateState(CHANNEL_PLANNER_NEXT_START, UnDefType.NULL);
310 updateState(CHANNEL_PLANNER_NEXT_START, new DateTimeType(toZonedDateTime(nextStartTimestamp)));
312 updateState(CHANNEL_PLANNER_OVERRIDE_ACTION,
313 new StringType(mower.getAttributes().getPlanner().getOverride().getAction()));
315 updateState(CHANNEL_CALENDAR_TASKS,
316 new StringType(gson.toJson(mower.getAttributes().getCalendar().getTasks())));
318 updateState(LAST_POSITION,
319 new PointType(new DecimalType(mower.getAttributes().getLastPosition().getLatitude()),
320 new DecimalType(mower.getAttributes().getLastPosition().getLongitude())));
321 ArrayList<Position> positions = mower.getAttributes().getPositions();
322 for (int i = 0; i < positions.size(); i++) {
323 updateState(CHANNEL_POSITIONS.get(i), new PointType(new DecimalType(positions.get(i).getLatitude()),
324 new DecimalType(positions.get(i).getLongitude())));
330 private void initializeProperties(@Nullable Mower mower) {
331 Map<String, String> properties = editProperties();
333 properties.put(AutomowerBindingConstants.AUTOMOWER_ID, mower.getId());
335 if (mower.getAttributes() != null && mower.getAttributes().getSystem() != null) {
336 properties.put(AutomowerBindingConstants.AUTOMOWER_SERIAL_NUMBER,
337 mower.getAttributes().getSystem().getSerialNumber());
338 properties.put(AutomowerBindingConstants.AUTOMOWER_MODEL, mower.getAttributes().getSystem().getModel());
339 properties.put(AutomowerBindingConstants.AUTOMOWER_NAME, mower.getAttributes().getSystem().getName());
342 updateProperties(properties);
346 * Converts timestamp returned by the Automower API into local time-zone.
347 * Timestamp returned by the API doesn't have offset and it always in the current time zone - it can be treated as
349 * Method builds a ZonedDateTime with same hour value but in the current system timezone.
351 * @param timestamp - Automower API timestamp
352 * @return ZonedDateTime in system timezone
354 private ZonedDateTime toZonedDateTime(long timestamp) {
355 Instant timestampInstant = Instant.ofEpochMilli(timestamp);
356 return ZonedDateTime.ofInstant(timestampInstant, timeZoneProvider.getTimeZone());