]> git.basschouten.com Git - openhab-addons.git/blob
3a4c7a53193ce661e59b5a7ec35bf0bce961a05c
[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.hydrawise.internal.handler;
14
15 import static org.openhab.binding.hydrawise.internal.HydrawiseBindingConstants.*;
16
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;
27 import java.util.Map;
28 import java.util.concurrent.atomic.AtomicReference;
29
30 import javax.measure.quantity.Speed;
31 import javax.measure.quantity.Temperature;
32 import javax.measure.quantity.Volume;
33
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;
69
70 /**
71  * The {@link HydrawiseControllerHandler} is responsible for handling commands, which are
72  * sent to one of the channels.
73  *
74  * @author Dan Cunningham - Initial contribution
75  */
76
77 @NonNullByDefault
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",
84             Locale.US);
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;
90
91     public HydrawiseControllerHandler(Thing thing) {
92         super(thing);
93     }
94
95     @Override
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);
106                 } else {
107                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
108                 }
109             }
110         }
111     }
112
113     @Override
114     public void dispose() {
115         logger.debug("Controller Handler disposed.");
116         HydrawiseAccountHandler handler = getAccountHandler();
117         if (handler != null) {
118             handler.removeControllerListeners(this);
119         }
120     }
121
122     @Override
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");
127             return;
128         }
129
130         // remove our cached state for this, will be safely updated on next poll
131         stateMap.remove(channelUID.getAsString());
132
133         if (command instanceof RefreshType) {
134             // we already removed this from the cache
135             return;
136         }
137
138         HydrawiseGraphQLClient client = apiClient();
139         if (client == null) {
140             logger.debug("API client not found");
141             return;
142         }
143
144         String group = channelUID.getGroupId();
145         String channelId = channelUID.getIdWithoutGroup();
146         boolean allCommand = CHANNEL_GROUP_ALLZONES.equals(group);
147         Zone zone = zoneMaps.get(group);
148
149         if (!allCommand && zone == null) {
150             logger.debug("Zone not found {}", group);
151             return;
152         }
153
154         try {
155             switch (channelId) {
156                 case CHANNEL_ZONE_RUN_CUSTOM:
157                     if (!(command instanceof QuantityType<?>)) {
158                         logger.warn("Invalid command type for run custom {}", command.getClass().getName());
159                         return;
160                     }
161                     QuantityType<?> time = ((QuantityType<?>) command).toUnit(Units.SECOND);
162
163                     if (time == null) {
164                         return;
165                     }
166
167                     if (allCommand) {
168                         client.runAllRelays(controllerId, time.intValue());
169                     } else if (zone != null) {
170                         client.runRelay(zone.id, time.intValue());
171                     }
172                     break;
173                 case CHANNEL_ZONE_RUN:
174                     if (!(command instanceof OnOffType)) {
175                         logger.warn("Invalid command type for run {}", command.getClass().getName());
176                         return;
177                     }
178                     if (allCommand) {
179                         if (command == OnOffType.ON) {
180                             client.runAllRelays(controllerId);
181                         } else {
182                             client.stopAllRelays(controllerId);
183                         }
184                     } else if (zone != null) {
185                         if (command == OnOffType.ON) {
186                             client.runRelay(zone.id);
187                         } else {
188                             client.stopRelay(zone.id);
189                         }
190                     }
191                     break;
192                 case CHANNEL_ZONE_SUSPEND:
193                     if (!(command instanceof OnOffType)) {
194                         logger.warn("Invalid command type for suspend {}", command.getClass().getName());
195                         return;
196                     }
197                     if (allCommand) {
198                         if (command == OnOffType.ON) {
199                             client.suspendAllRelays(controllerId, OffsetDateTime.now(ZoneOffset.UTC)
200                                     .plus(DEFAULT_SUSPEND_TIME_HOURS, ChronoUnit.HOURS).format(DATE_FORMATTER));
201                         } else {
202                             client.resumeAllRelays(controllerId);
203                         }
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));
208                         } else {
209                             client.resumeRelay(zone.id);
210                         }
211                     }
212                     break;
213                 case CHANNEL_ZONE_SUSPENDUNTIL:
214                     if (!(command instanceof DateTimeType)) {
215                         logger.warn("Invalid command type for suspend {}", command.getClass().getName());
216                         return;
217                     }
218                     if (allCommand) {
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));
224                     }
225                     break;
226                 default:
227                     logger.warn("Uknown channelId {}", channelId);
228                     return;
229             }
230             HydrawiseAccountHandler handler = getAccountHandler();
231             if (handler != null) {
232                 handler.refreshData(DEFAULT_REFRESH_SECONDS);
233             }
234         } catch (HydrawiseCommandException | HydrawiseConnectionException e) {
235             logger.debug("Could not issue command", e);
236         } catch (HydrawiseAuthenticationException e) {
237             logger.debug("Credentials not valid");
238         }
239     }
240
241     @Override
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);
250             }
251             if (controller.location != null && controller.location.forecast != null) {
252                 updateForecast(controller.location.forecast);
253             }
254             if (controller.zones != null) {
255                 updateZones(controller.zones);
256             }
257
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);
264             }
265         });
266     }
267
268     @Override
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());
272     }
273
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)
280                         : UnDefType.NULL);
281     }
282
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);
293
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)
302                                 : UnDefType.UNDEF);
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));
308                 } else {
309                     updateGroupState(group, CHANNEL_ZONE_DURATION, UnDefType.UNDEF);
310                     updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME, UnDefType.UNDEF);
311                 }
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);
318                 } else {
319                     updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.OFF);
320                     updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(0, Units.MINUTE));
321                 }
322             }
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);
328             } else {
329                 updateGroupState(group, CHANNEL_ZONE_SUSPEND, OnOffType.OFF);
330                 updateGroupState(group, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
331             }
332         }
333         updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, OnOffType.from(anyRunning.get()));
334         updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPEND, OnOffType.from(anySuspended.get()));
335         updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
336     }
337
338     private void updateSensors(List<Sensor> sensors) {
339         int i = 1;
340         for (Sensor sensor : sensors) {
341             String group = "sensor" + (i++);
342             updateGroupState(group, CHANNEL_SENSOR_NAME, new StringType(sensor.name));
343             if (sensor.model.offTimer != null) {
344                 updateGroupState(group, CHANNEL_SENSOR_OFFTIMER,
345                         new QuantityType<>(sensor.model.offTimer, Units.SECOND));
346             }
347             if (sensor.model.delay != null) {
348                 updateGroupState(group, CHANNEL_SENSOR_DELAY, new QuantityType<>(sensor.model.delay, Units.SECOND));
349             }
350             if (sensor.model.offLevel != null) {
351                 updateGroupState(group, CHANNEL_SENSOR_OFFLEVEL, new DecimalType(sensor.model.offLevel));
352             }
353             if (sensor.status.active != null) {
354                 updateGroupState(group, CHANNEL_SENSOR_ACTIVE, OnOffType.from(sensor.status.active));
355             }
356             if (sensor.status.waterFlow != null) {
357                 updateGroupState(group, CHANNEL_SENSOR_WATERFLOW,
358                         waterFlowToQuantityType(sensor.status.waterFlow.value, sensor.status.waterFlow.unit));
359             }
360         }
361     }
362
363     private void updateForecast(List<Forecast> forecasts) {
364         int i = 1;
365         for (Forecast forecast : forecasts) {
366             String group = "forecast" + (i++);
367             updateGroupState(group, CHANNEL_FORECAST_TIME, stringToDateTime(forecast.time));
368             updateGroupState(group, CHANNEL_FORECAST_CONDITIONS, new StringType(forecast.conditions));
369             updateGroupState(group, CHANNEL_FORECAST_HUMIDITY, new DecimalType(forecast.averageHumidity.intValue()));
370             updateTemperature(forecast.highTemperature, group, CHANNEL_FORECAST_TEMPERATURE_HIGH);
371             updateTemperature(forecast.lowTemperature, group, CHANNEL_FORECAST_TEMPERATURE_LOW);
372             updateWindspeed(forecast.averageWindSpeed, group, CHANNEL_FORECAST_WIND);
373             // this seems to sometimes be optional
374             if (forecast.evapotranspiration != null) {
375                 updateGroupState(group, CHANNEL_FORECAST_EVAPOTRANSPRIATION,
376                         new DecimalType(forecast.evapotranspiration.value.floatValue()));
377             }
378             updateGroupState(group, CHANNEL_FORECAST_PRECIPITATION,
379                     new DecimalType(forecast.precipitation.value.floatValue()));
380             updateGroupState(group, CHANNEL_FORECAST_PROBABILITYOFPRECIPITATION,
381                     new DecimalType(forecast.probabilityOfPrecipitation));
382
383         }
384     }
385
386     private void updateTemperature(UnitValue temperature, String group, String channel) {
387         logger.debug("TEMP {} {} {} {}", group, channel, temperature.unit, temperature.value);
388         updateGroupState(group, channel, new QuantityType<Temperature>(temperature.value,
389                 "\\u00b0F".equals(temperature.unit) ? ImperialUnits.FAHRENHEIT : SIUnits.CELSIUS));
390     }
391
392     private void updateWindspeed(UnitValue wind, String group, String channel) {
393         updateGroupState(group, channel, new QuantityType<Speed>(wind.value,
394                 "mph".equals(wind.unit) ? ImperialUnits.MILES_PER_HOUR : SIUnits.KILOMETRE_PER_HOUR));
395     }
396
397     private void updateGroupState(String group, String channelID, State state) {
398         String channelName = group + "#" + channelID;
399         State oldState = stateMap.put(channelName, state);
400         if (!state.equals(oldState)) {
401             ChannelUID channelUID = new ChannelUID(this.getThing().getUID(), channelName);
402             logger.debug("updateState updating {} {}", channelUID, state);
403             updateState(channelUID, state);
404         }
405     }
406
407     @Nullable
408     private HydrawiseAccountHandler getAccountHandler() {
409         Bridge bridge = getBridge();
410         if (bridge == null) {
411             logger.warn("No bridge found for thing");
412             return null;
413         }
414         BridgeHandler handler = bridge.getHandler();
415         if (handler == null) {
416             logger.warn("No handler found for bridge");
417             return null;
418         }
419         return ((HydrawiseAccountHandler) handler);
420     }
421
422     @Nullable
423     private HydrawiseGraphQLClient apiClient() {
424         HydrawiseAccountHandler handler = getAccountHandler();
425         if (handler == null) {
426             return null;
427         } else {
428             return handler.graphQLClient();
429         }
430     }
431
432     private DateTimeType secondsToDateTime(Integer seconds) {
433         Instant instant = Instant.ofEpochSecond(seconds);
434         ZonedDateTime zdt = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC);
435         return new DateTimeType(zdt);
436     }
437
438     private DateTimeType stringToDateTime(String date) {
439         ZonedDateTime zdt = ZonedDateTime.parse(date, DATE_FORMATTER);
440         return new DateTimeType(zdt);
441     }
442
443     private QuantityType<Volume> waterFlowToQuantityType(Number flow, String units) {
444         double waterFlow = flow.doubleValue();
445         if ("gals".equals(units)) {
446             waterFlow = waterFlow * 3.785;
447         }
448         return new QuantityType<>(waterFlow, Units.LITRE);
449     }
450 }