2 * Copyright (c) 2010-2023 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.Instant;
18 import java.time.OffsetDateTime;
19 import java.time.ZoneOffset;
20 import java.time.ZonedDateTime;
21 import java.time.format.DateTimeFormatter;
22 import java.time.temporal.ChronoUnit;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
26 import java.util.Locale;
28 import java.util.concurrent.atomic.AtomicReference;
30 import javax.measure.quantity.Speed;
31 import javax.measure.quantity.Temperature;
32 import javax.measure.quantity.Volume;
34 import org.eclipse.jdt.annotation.NonNullByDefault;
35 import org.eclipse.jdt.annotation.Nullable;
36 import org.openhab.binding.hydrawise.internal.HydrawiseControllerListener;
37 import org.openhab.binding.hydrawise.internal.api.HydrawiseAuthenticationException;
38 import org.openhab.binding.hydrawise.internal.api.HydrawiseCommandException;
39 import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
40 import org.openhab.binding.hydrawise.internal.api.graphql.HydrawiseGraphQLClient;
41 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Controller;
42 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Forecast;
43 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Sensor;
44 import org.openhab.binding.hydrawise.internal.api.graphql.dto.UnitValue;
45 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Zone;
46 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ZoneRun;
47 import org.openhab.binding.hydrawise.internal.config.HydrawiseControllerConfiguration;
48 import org.openhab.core.library.types.DateTimeType;
49 import org.openhab.core.library.types.DecimalType;
50 import org.openhab.core.library.types.OnOffType;
51 import org.openhab.core.library.types.QuantityType;
52 import org.openhab.core.library.types.StringType;
53 import org.openhab.core.library.unit.ImperialUnits;
54 import org.openhab.core.library.unit.SIUnits;
55 import org.openhab.core.library.unit.Units;
56 import org.openhab.core.thing.Bridge;
57 import org.openhab.core.thing.ChannelUID;
58 import org.openhab.core.thing.Thing;
59 import org.openhab.core.thing.ThingStatus;
60 import org.openhab.core.thing.ThingStatusDetail;
61 import org.openhab.core.thing.binding.BaseThingHandler;
62 import org.openhab.core.thing.binding.BridgeHandler;
63 import org.openhab.core.types.Command;
64 import org.openhab.core.types.RefreshType;
65 import org.openhab.core.types.State;
66 import org.openhab.core.types.UnDefType;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
71 * The {@link HydrawiseControllerHandler} is responsible for handling commands, which are
72 * sent to one of the channels.
74 * @author Dan Cunningham - Initial contribution
78 public class HydrawiseControllerHandler extends BaseThingHandler implements HydrawiseControllerListener {
79 private final Logger logger = LoggerFactory.getLogger(HydrawiseControllerHandler.class);
80 private static final int DEFAULT_SUSPEND_TIME_HOURS = 24;
81 private static final int DEFAULT_REFRESH_SECONDS = 15;
82 // All responses use US local time formats
83 private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM uu HH:mm:ss Z",
85 private final Map<String, @Nullable State> stateMap = Collections
86 .synchronizedMap(new HashMap<String, @Nullable State>());
87 private final Map<String, @Nullable Zone> zoneMaps = Collections
88 .synchronizedMap(new HashMap<String, @Nullable Zone>());
89 private int controllerId;
91 public HydrawiseControllerHandler(Thing thing) {
96 public void initialize() {
97 HydrawiseControllerConfiguration config = getConfigAs(HydrawiseControllerConfiguration.class);
98 controllerId = config.controllerId;
99 HydrawiseAccountHandler handler = getAccountHandler();
100 if (handler != null) {
101 handler.addControllerListeners(this);
102 Bridge bridge = getBridge();
103 if (bridge != null) {
104 if (bridge.getStatus() == ThingStatus.ONLINE) {
105 updateStatus(ThingStatus.ONLINE);
107 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
114 public void dispose() {
115 logger.debug("Controller Handler disposed.");
116 HydrawiseAccountHandler handler = getAccountHandler();
117 if (handler != null) {
118 handler.removeControllerListeners(this);
123 public void handleCommand(ChannelUID channelUID, Command command) {
124 logger.debug("handleCommand channel {} Command {}", channelUID.getAsString(), command.toFullString());
125 if (getThing().getStatus() != ThingStatus.ONLINE) {
126 logger.debug("Controller is NOT ONLINE and is not responding to commands");
130 // remove our cached state for this, will be safely updated on next poll
131 stateMap.remove(channelUID.getAsString());
133 if (command instanceof RefreshType) {
134 // we already removed this from the cache
138 HydrawiseGraphQLClient client = apiClient();
139 if (client == null) {
140 logger.debug("API client not found");
144 String group = channelUID.getGroupId();
145 String channelId = channelUID.getIdWithoutGroup();
146 boolean allCommand = CHANNEL_GROUP_ALLZONES.equals(group);
147 Zone zone = zoneMaps.get(group);
149 if (!allCommand && zone == null) {
150 logger.debug("Zone not found {}", group);
156 case CHANNEL_ZONE_RUN_CUSTOM:
157 if (!(command instanceof QuantityType<?>)) {
158 logger.warn("Invalid command type for run custom {}", command.getClass().getName());
161 QuantityType<?> time = ((QuantityType<?>) command).toUnit(Units.SECOND);
168 client.runAllRelays(controllerId, time.intValue());
169 } else if (zone != null) {
170 client.runRelay(zone.id, time.intValue());
173 case CHANNEL_ZONE_RUN:
174 if (!(command instanceof OnOffType)) {
175 logger.warn("Invalid command type for run {}", command.getClass().getName());
179 if (command == OnOffType.ON) {
180 client.runAllRelays(controllerId);
182 client.stopAllRelays(controllerId);
184 } else if (zone != null) {
185 if (command == OnOffType.ON) {
186 client.runRelay(zone.id);
188 client.stopRelay(zone.id);
192 case CHANNEL_ZONE_SUSPEND:
193 if (!(command instanceof OnOffType)) {
194 logger.warn("Invalid command type for suspend {}", command.getClass().getName());
198 if (command == OnOffType.ON) {
199 client.suspendAllRelays(controllerId, OffsetDateTime.now(ZoneOffset.UTC)
200 .plus(DEFAULT_SUSPEND_TIME_HOURS, ChronoUnit.HOURS).format(DATE_FORMATTER));
202 client.resumeAllRelays(controllerId);
204 } else if (zone != null) {
205 if (command == OnOffType.ON) {
206 client.suspendRelay(zone.id, OffsetDateTime.now(ZoneOffset.UTC)
207 .plus(DEFAULT_SUSPEND_TIME_HOURS, ChronoUnit.HOURS).format(DATE_FORMATTER));
209 client.resumeRelay(zone.id);
213 case CHANNEL_ZONE_SUSPENDUNTIL:
214 if (!(command instanceof DateTimeType)) {
215 logger.warn("Invalid command type for suspend {}", command.getClass().getName());
219 client.suspendAllRelays(controllerId,
220 ((DateTimeType) command).getZonedDateTime().format(DATE_FORMATTER));
221 } else if (zone != null) {
222 client.suspendRelay(zone.id,
223 ((DateTimeType) command).getZonedDateTime().format(DATE_FORMATTER));
227 logger.warn("Uknown channelId {}", channelId);
230 HydrawiseAccountHandler handler = getAccountHandler();
231 if (handler != null) {
232 handler.refreshData(DEFAULT_REFRESH_SECONDS);
234 } catch (HydrawiseCommandException | HydrawiseConnectionException e) {
235 logger.debug("Could not issue command", e);
236 } catch (HydrawiseAuthenticationException e) {
237 logger.debug("Credentials not valid");
242 public void onData(List<Controller> controllers) {
243 logger.trace("onData my controller id {}", controllerId);
244 controllers.stream().filter(c -> c.id == controllerId).findFirst().ifPresent(controller -> {
245 logger.trace("Updating Controller {} sensors {} forecast {} ", controller.id, controller.sensors,
246 controller.location.forecast);
247 updateController(controller);
248 if (controller.sensors != null) {
249 updateSensors(controller.sensors);
251 if (controller.location != null && controller.location.forecast != null) {
252 updateForecast(controller.location.forecast);
254 if (controller.zones != null) {
255 updateZones(controller.zones);
258 // update values with what the cloud tells us even though the controller may be offline
259 if (!controller.status.online) {
260 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
261 "Service reports controller as offline");
262 } else if (getThing().getStatus() != ThingStatus.ONLINE) {
263 updateStatus(ThingStatus.ONLINE);
269 public void channelLinked(ChannelUID channelUID) {
270 // clear our cached value so the new channel gets updated on the next poll
271 stateMap.remove(channelUID.getId());
274 private void updateController(Controller controller) {
275 updateGroupState(CHANNEL_GROUP_CONTROLLER_SYSTEM, CHANNEL_CONTROLLER_NAME, new StringType(controller.name));
276 updateGroupState(CHANNEL_GROUP_CONTROLLER_SYSTEM, CHANNEL_CONTROLLER_SUMMARY,
277 new StringType(controller.status.summary));
278 updateGroupState(CHANNEL_GROUP_CONTROLLER_SYSTEM, CHANNEL_CONTROLLER_LAST_CONTACT,
279 controller.status.lastContact != null ? secondsToDateTime(controller.status.lastContact.timestamp)
283 private void updateZones(List<Zone> zones) {
284 AtomicReference<Boolean> anyRunning = new AtomicReference<Boolean>(false);
285 AtomicReference<Boolean> anySuspended = new AtomicReference<Boolean>(false);
286 for (Zone zone : zones) {
287 // there are 12 relays per expander, expanders will have a zoneNumber like:
288 // 10 for expander 0, relay 10 = zone10
289 // 101 for expander 1, relay 1 = zone13
290 // 212 for expander 2, relay 12 = zone36
291 // division of integers in Java give whole numbers, not remainders FYI
292 int zoneNumber = ((zone.number.value / 100) * 12) + (zone.number.value % 100);
294 String group = "zone" + zoneNumber;
295 zoneMaps.put(group, zone);
296 logger.trace("Updateing Zone {} {} ", group, zone.name);
297 updateGroupState(group, CHANNEL_ZONE_NAME, new StringType(zone.name));
298 updateGroupState(group, CHANNEL_ZONE_ICON, new StringType(BASE_IMAGE_URL + zone.icon.fileName));
299 if (zone.scheduledRuns != null) {
300 updateGroupState(group, CHANNEL_ZONE_SUMMARY,
301 zone.scheduledRuns.summary != null ? new StringType(zone.scheduledRuns.summary)
303 ZoneRun nextRun = zone.scheduledRuns.nextRun;
304 if (nextRun != null) {
305 updateGroupState(group, CHANNEL_ZONE_DURATION, new QuantityType<>(nextRun.duration, Units.MINUTE));
306 updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME,
307 secondsToDateTime(nextRun.startTime.timestamp));
309 updateGroupState(group, CHANNEL_ZONE_DURATION, UnDefType.UNDEF);
310 updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME, UnDefType.UNDEF);
312 ZoneRun currRunn = zone.scheduledRuns.currentRun;
313 if (currRunn != null) {
314 updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.ON);
315 updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(
316 currRunn.endTime.timestamp - Instant.now().getEpochSecond(), Units.SECOND));
317 anyRunning.set(true);
319 updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.OFF);
320 updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(0, Units.MINUTE));
323 if (zone.status.suspendedUntil != null) {
324 updateGroupState(group, CHANNEL_ZONE_SUSPEND, OnOffType.ON);
325 updateGroupState(group, CHANNEL_ZONE_SUSPENDUNTIL,
326 secondsToDateTime(zone.status.suspendedUntil.timestamp));
327 anySuspended.set(true);
329 updateGroupState(group, CHANNEL_ZONE_SUSPEND, OnOffType.OFF);
330 updateGroupState(group, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
333 updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, anyRunning.get() ? OnOffType.ON : OnOffType.OFF);
334 updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPEND,
335 anySuspended.get() ? OnOffType.ON : OnOffType.OFF);
336 updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
339 private void updateSensors(List<Sensor> sensors) {
341 for (Sensor sensor : sensors) {
342 String group = "sensor" + (i++);
343 updateGroupState(group, CHANNEL_SENSOR_NAME, new StringType(sensor.name));
344 if (sensor.model.offTimer != null) {
345 updateGroupState(group, CHANNEL_SENSOR_OFFTIMER,
346 new QuantityType<>(sensor.model.offTimer, Units.SECOND));
348 if (sensor.model.delay != null) {
349 updateGroupState(group, CHANNEL_SENSOR_DELAY, new QuantityType<>(sensor.model.delay, Units.SECOND));
351 if (sensor.model.offLevel != null) {
352 updateGroupState(group, CHANNEL_SENSOR_OFFLEVEL, new DecimalType(sensor.model.offLevel));
354 if (sensor.status.active != null) {
355 updateGroupState(group, CHANNEL_SENSOR_ACTIVE, sensor.status.active ? OnOffType.ON : OnOffType.OFF);
357 if (sensor.status.waterFlow != null) {
358 updateGroupState(group, CHANNEL_SENSOR_WATERFLOW,
359 waterFlowToQuantityType(sensor.status.waterFlow.value, sensor.status.waterFlow.unit));
364 private void updateForecast(List<Forecast> forecasts) {
366 for (Forecast forecast : forecasts) {
367 String group = "forecast" + (i++);
368 updateGroupState(group, CHANNEL_FORECAST_TIME, stringToDateTime(forecast.time));
369 updateGroupState(group, CHANNEL_FORECAST_CONDITIONS, new StringType(forecast.conditions));
370 updateGroupState(group, CHANNEL_FORECAST_HUMIDITY, new DecimalType(forecast.averageHumidity.intValue()));
371 updateTemperature(forecast.highTemperature, group, CHANNEL_FORECAST_TEMPERATURE_HIGH);
372 updateTemperature(forecast.lowTemperature, group, CHANNEL_FORECAST_TEMPERATURE_LOW);
373 updateWindspeed(forecast.averageWindSpeed, group, CHANNEL_FORECAST_WIND);
374 // this seems to sometimes be optional
375 if (forecast.evapotranspiration != null) {
376 updateGroupState(group, CHANNEL_FORECAST_EVAPOTRANSPRIATION,
377 new DecimalType(forecast.evapotranspiration.value.floatValue()));
379 updateGroupState(group, CHANNEL_FORECAST_PRECIPITATION,
380 new DecimalType(forecast.precipitation.value.floatValue()));
381 updateGroupState(group, CHANNEL_FORECAST_PROBABILITYOFPRECIPITATION,
382 new DecimalType(forecast.probabilityOfPrecipitation));
387 private void updateTemperature(UnitValue temperature, String group, String channel) {
388 logger.debug("TEMP {} {} {} {}", group, channel, temperature.unit, temperature.value);
389 updateGroupState(group, channel, new QuantityType<Temperature>(temperature.value,
390 "\\u00b0F".equals(temperature.unit) ? ImperialUnits.FAHRENHEIT : SIUnits.CELSIUS));
393 private void updateWindspeed(UnitValue wind, String group, String channel) {
394 updateGroupState(group, channel, new QuantityType<Speed>(wind.value,
395 "mph".equals(wind.unit) ? ImperialUnits.MILES_PER_HOUR : SIUnits.KILOMETRE_PER_HOUR));
398 private void updateGroupState(String group, String channelID, State state) {
399 String channelName = group + "#" + channelID;
400 State oldState = stateMap.put(channelName, state);
401 if (!state.equals(oldState)) {
402 ChannelUID channelUID = new ChannelUID(this.getThing().getUID(), channelName);
403 logger.debug("updateState updating {} {}", channelUID, state);
404 updateState(channelUID, state);
409 private HydrawiseAccountHandler getAccountHandler() {
410 Bridge bridge = getBridge();
411 if (bridge == null) {
412 logger.warn("No bridge found for thing");
415 BridgeHandler handler = bridge.getHandler();
416 if (handler == null) {
417 logger.warn("No handler found for bridge");
420 return ((HydrawiseAccountHandler) handler);
424 private HydrawiseGraphQLClient apiClient() {
425 HydrawiseAccountHandler handler = getAccountHandler();
426 if (handler == null) {
429 return handler.graphQLClient();
433 private DateTimeType secondsToDateTime(Integer seconds) {
434 Instant instant = Instant.ofEpochSecond(seconds);
435 ZonedDateTime zdt = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC);
436 return new DateTimeType(zdt);
439 private DateTimeType stringToDateTime(String date) {
440 ZonedDateTime zdt = ZonedDateTime.parse(date, DATE_FORMATTER);
441 return new DateTimeType(zdt);
444 private QuantityType<Volume> waterFlowToQuantityType(Number flow, String units) {
445 double waterFlow = flow.doubleValue();
446 if ("gals".equals(units)) {
447 waterFlow = waterFlow * 3.785;
449 return new QuantityType<>(waterFlow, Units.LITRE);