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.hydrawise.internal.handler;
15 import static org.openhab.binding.hydrawise.internal.HydrawiseBindingConstants.*;
17 import java.time.ZonedDateTime;
18 import java.time.temporal.ChronoUnit;
19 import java.util.Collections;
20 import java.util.HashMap;
22 import java.util.Optional;
23 import java.util.concurrent.ScheduledFuture;
24 import java.util.concurrent.TimeUnit;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.eclipse.jetty.client.HttpClient;
29 import org.openhab.binding.hydrawise.internal.api.HydrawiseAuthenticationException;
30 import org.openhab.binding.hydrawise.internal.api.HydrawiseCommandException;
31 import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
32 import org.openhab.binding.hydrawise.internal.api.local.HydrawiseLocalApiClient;
33 import org.openhab.binding.hydrawise.internal.api.local.dto.LocalScheduleResponse;
34 import org.openhab.binding.hydrawise.internal.api.local.dto.Relay;
35 import org.openhab.binding.hydrawise.internal.api.local.dto.Running;
36 import org.openhab.binding.hydrawise.internal.config.HydrawiseLocalConfiguration;
37 import org.openhab.core.library.types.DateTimeType;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.library.types.OnOffType;
40 import org.openhab.core.library.types.QuantityType;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.library.unit.Units;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.BaseThingHandler;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.RefreshType;
50 import org.openhab.core.types.State;
51 import org.openhab.core.types.UnDefType;
52 import org.slf4j.Logger;
53 import org.slf4j.LoggerFactory;
56 * The {@link HydrawiseLocalHandler} is responsible for handling commands, which are
57 * sent to one of the channels.
59 * @author Dan Cunningham - Initial contribution
62 public class HydrawiseLocalHandler extends BaseThingHandler {
63 private final Logger logger = LoggerFactory.getLogger(HydrawiseLocalHandler.class);
64 protected final Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
65 protected final Map<String, Relay> relayMap = Collections.synchronizedMap(new HashMap<>());
66 private @Nullable ScheduledFuture<?> pollFuture;
69 * value observed being used by the Hydrawise clients as a max time value,
71 private static final long MAX_RUN_TIME = 157680000;
74 * Minimum amount of time we can poll for updates
76 protected static final int MIN_REFRESH_SECONDS = 5;
79 * Minimum amount of time we can poll after a command
81 protected static final int COMMAND_REFRESH_SECONDS = 5;
86 protected int refresh;
89 * Future to poll for updated
92 HydrawiseLocalApiClient client;
94 public HydrawiseLocalHandler(Thing thing, HttpClient httpClient) {
96 client = new HydrawiseLocalApiClient(httpClient);
100 public void initialize() {
101 scheduler.schedule(this::configureInternal, 0, TimeUnit.SECONDS);
105 public void dispose() {
106 logger.debug("Handler disposed.");
111 public void channelLinked(ChannelUID channelUID) {
112 // clear our cached value so the new channel gets updated on the next poll
113 stateMap.remove(channelUID.getId());
116 @SuppressWarnings({ "null", "unused" }) // compiler does not like relayMap.get can return null
118 public void handleCommand(ChannelUID channelUID, Command command) {
119 if (getThing().getStatus() != ThingStatus.ONLINE) {
120 logger.warn("Controller is NOT ONLINE and is not responding to commands");
124 // remove our cached state for this, will be safely updated on next poll
125 stateMap.remove(channelUID.getAsString());
127 if (command instanceof RefreshType) {
128 // we already removed this from the cache
132 String group = channelUID.getGroupId();
133 String channelId = channelUID.getIdWithoutGroup();
134 boolean allCommand = CHANNEL_GROUP_ALLZONES.equals(group);
136 Relay relay = relayMap.get(group);
137 if (!allCommand && relay == null) {
138 logger.debug("Zone not found {}", group);
145 case CHANNEL_ZONE_RUN_CUSTOM:
146 if (!(command instanceof QuantityType<?>)) {
147 logger.warn("Invalid command type for run custom {}", command.getClass().getName());
151 client.runAllRelays(((QuantityType<?>) command).intValue());
153 client.runRelay(((QuantityType<?>) command).intValue(), relay.relay);
156 case CHANNEL_ZONE_RUN:
157 if (!(command instanceof OnOffType)) {
158 logger.warn("Invalid command type for run {}", command.getClass().getName());
162 if (command == OnOffType.ON) {
163 client.runAllRelays();
165 client.stopAllRelays();
168 if (command == OnOffType.ON) {
169 client.runRelay(relay.relay);
171 client.stopRelay(relay.relay);
176 initPolling(COMMAND_REFRESH_SECONDS);
177 } catch (HydrawiseCommandException | HydrawiseConnectionException e) {
178 logger.debug("Could not issue command", e);
179 initPolling(COMMAND_REFRESH_SECONDS);
180 } catch (HydrawiseAuthenticationException e) {
181 logger.debug("Credentials not valid");
182 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Credentials not valid");
187 protected void updateZones(LocalScheduleResponse status) {
188 ZonedDateTime now = ZonedDateTime.now().truncatedTo(ChronoUnit.SECONDS);
189 status.relays.forEach(r -> {
190 String group = "zone" + r.getRelayNumber();
191 relayMap.put(group, r);
192 logger.trace("Updateing Zone {} {} ", group, r.name);
193 updateGroupState(group, CHANNEL_ZONE_NAME, new StringType(r.name));
194 updateGroupState(group, CHANNEL_ZONE_TYPE, new DecimalType(r.type));
195 updateGroupState(group, CHANNEL_ZONE_STARTTIME,
196 r.runSeconds != null ? new QuantityType<>(r.runSeconds, Units.SECOND) : UnDefType.UNDEF);
197 if (r.time >= MAX_RUN_TIME) {
198 updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME, UnDefType.UNDEF);
200 updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME,
201 new DateTimeType(now.plusSeconds(r.time).truncatedTo(ChronoUnit.MINUTES)));
204 Optional<Running> running = status.running.stream()
205 .filter(z -> Integer.parseInt(z.relayId) == r.relayId.intValue()).findAny();
206 if (running.isPresent()) {
207 updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.ON);
208 updateGroupState(group, CHANNEL_ZONE_TIME_LEFT,
209 new QuantityType<>(running.get().timeLeft, Units.SECOND));
210 logger.debug("{} Time Left {}", r.name, running.get().timeLeft);
213 updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.OFF);
214 updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(0, Units.SECOND));
217 updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, OnOffType.from(!status.running.isEmpty()));
221 @SuppressWarnings("serial")
222 protected class NotConfiguredException extends Exception {
223 NotConfiguredException(String message) {
228 private boolean isFutureValid(@Nullable ScheduledFuture<?> future) {
229 return future != null && !future.isCancelled();
232 private void configureInternal() {
237 HydrawiseLocalConfiguration configuration = getConfig().as(HydrawiseLocalConfiguration.class);
238 this.refresh = Math.max(configuration.refresh, MIN_REFRESH_SECONDS);
239 logger.trace("Connecting to host {}", configuration.host);
240 client.setCredentials(configuration.host, configuration.username, configuration.password);
241 LocalScheduleResponse response = client.getLocalSchedule();
242 if (response != null) {
243 updateZones(response);
244 initPolling(refresh);
246 logger.debug("Could not connect to service");
247 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
248 "Invalid response from service");
250 } catch (HydrawiseConnectionException e) {
251 logger.debug("Could not connect to service");
252 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
253 } catch (HydrawiseAuthenticationException e) {
254 logger.debug("Credentials not valid");
255 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Credentials not valid");
260 * Starts/Restarts polling with an initial delay. This allows changes in the poll cycle for when commands are sent
261 * and we need to poll sooner then the next refresh cycle.
263 private synchronized void initPolling(int initalDelay) {
265 pollFuture = scheduler.scheduleWithFixedDelay(this::pollControllerInternal, initalDelay, refresh,
270 * Stops/clears this thing's polling future
272 private void clearPolling() {
273 ScheduledFuture<?> localFuture = pollFuture;
274 if (isFutureValid(localFuture)) {
275 if (localFuture != null) {
276 localFuture.cancel(false);
282 * Poll the controller for updates.
284 private void pollControllerInternal() {
286 LocalScheduleResponse response = client.getLocalSchedule();
287 if (response != null) {
288 updateZones(response);
290 if (getThing().getStatus() != ThingStatus.ONLINE) {
291 updateStatus(ThingStatus.ONLINE);
293 } catch (HydrawiseConnectionException e) {
294 // poller will continue to run, set offline until next run
295 logger.debug("Exception polling", e);
296 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
297 } catch (HydrawiseAuthenticationException e) {
298 // if are creds are not valid, we need to try re authorizing again
299 logger.debug("Authorization exception during polling", e);
304 private void updateGroupState(String group, String channelID, State state) {
305 String channelName = group + "#" + channelID;
306 State oldState = stateMap.put(channelName, state);
307 if (!state.equals(oldState)) {
308 ChannelUID channelUID = new ChannelUID(this.getThing().getUID(), channelName);
309 logger.debug("updateState updating {} {}", channelUID, state);
310 updateState(channelUID, state);