]> git.basschouten.com Git - openhab-addons.git/blob
60c90f82ef1dfd432f65b567d56078aedc6d6967
[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.Volume;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.hydrawise.internal.HydrawiseControllerListener;
35 import org.openhab.binding.hydrawise.internal.api.HydrawiseAuthenticationException;
36 import org.openhab.binding.hydrawise.internal.api.HydrawiseCommandException;
37 import org.openhab.binding.hydrawise.internal.api.HydrawiseConnectionException;
38 import org.openhab.binding.hydrawise.internal.api.graphql.HydrawiseGraphQLClient;
39 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Controller;
40 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Forecast;
41 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Sensor;
42 import org.openhab.binding.hydrawise.internal.api.graphql.dto.UnitValue;
43 import org.openhab.binding.hydrawise.internal.api.graphql.dto.Zone;
44 import org.openhab.binding.hydrawise.internal.api.graphql.dto.ZoneRun;
45 import org.openhab.binding.hydrawise.internal.config.HydrawiseControllerConfiguration;
46 import org.openhab.core.library.types.DateTimeType;
47 import org.openhab.core.library.types.DecimalType;
48 import org.openhab.core.library.types.OnOffType;
49 import org.openhab.core.library.types.QuantityType;
50 import org.openhab.core.library.types.StringType;
51 import org.openhab.core.library.unit.ImperialUnits;
52 import org.openhab.core.library.unit.SIUnits;
53 import org.openhab.core.library.unit.Units;
54 import org.openhab.core.thing.Bridge;
55 import org.openhab.core.thing.ChannelUID;
56 import org.openhab.core.thing.Thing;
57 import org.openhab.core.thing.ThingStatus;
58 import org.openhab.core.thing.ThingStatusDetail;
59 import org.openhab.core.thing.binding.BaseThingHandler;
60 import org.openhab.core.thing.binding.BridgeHandler;
61 import org.openhab.core.types.Command;
62 import org.openhab.core.types.RefreshType;
63 import org.openhab.core.types.State;
64 import org.openhab.core.types.UnDefType;
65 import org.slf4j.Logger;
66 import org.slf4j.LoggerFactory;
67
68 /**
69  * The {@link HydrawiseControllerHandler} is responsible for handling commands, which are
70  * sent to one of the channels.
71  *
72  * @author Dan Cunningham - Initial contribution
73  */
74
75 @NonNullByDefault
76 public class HydrawiseControllerHandler extends BaseThingHandler implements HydrawiseControllerListener {
77     private final Logger logger = LoggerFactory.getLogger(HydrawiseControllerHandler.class);
78     private static final int DEFAULT_SUSPEND_TIME_HOURS = 24;
79     private static final int DEFAULT_REFRESH_SECONDS = 15;
80     // All responses use US local time formats
81     private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM uu HH:mm:ss Z",
82             Locale.US);
83     private final Map<String, @Nullable State> stateMap = Collections
84             .synchronizedMap(new HashMap<String, @Nullable State>());
85     private final Map<String, @Nullable Zone> zoneMaps = Collections
86             .synchronizedMap(new HashMap<String, @Nullable Zone>());
87     private int controllerId;
88
89     public HydrawiseControllerHandler(Thing thing) {
90         super(thing);
91     }
92
93     @Override
94     public void initialize() {
95         HydrawiseControllerConfiguration config = getConfigAs(HydrawiseControllerConfiguration.class);
96         controllerId = config.controllerId;
97         HydrawiseAccountHandler handler = getAccountHandler();
98         if (handler != null) {
99             handler.addControllerListeners(this);
100             Bridge bridge = getBridge();
101             if (bridge != null) {
102                 if (bridge.getStatus() == ThingStatus.ONLINE) {
103                     updateStatus(ThingStatus.ONLINE);
104                 } else {
105                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
106                 }
107             }
108         }
109     }
110
111     @Override
112     public void dispose() {
113         logger.debug("Controller Handler disposed.");
114         HydrawiseAccountHandler handler = getAccountHandler();
115         if (handler != null) {
116             handler.removeControllerListeners(this);
117         }
118     }
119
120     @Override
121     public void handleCommand(ChannelUID channelUID, Command command) {
122         logger.debug("handleCommand channel {} Command {}", channelUID.getAsString(), command.toFullString());
123         if (getThing().getStatus() != ThingStatus.ONLINE) {
124             logger.debug("Controller is NOT ONLINE and is not responding to commands");
125             return;
126         }
127
128         // remove our cached state for this, will be safely updated on next poll
129         stateMap.remove(channelUID.getAsString());
130
131         if (command instanceof RefreshType) {
132             // we already removed this from the cache
133             return;
134         }
135
136         HydrawiseGraphQLClient client = apiClient();
137         if (client == null) {
138             logger.debug("API client not found");
139             return;
140         }
141
142         String group = channelUID.getGroupId();
143         String channelId = channelUID.getIdWithoutGroup();
144         boolean allCommand = CHANNEL_GROUP_ALLZONES.equals(group);
145         Zone zone = zoneMaps.get(group);
146
147         if (!allCommand && zone == null) {
148             logger.debug("Zone not found {}", group);
149             return;
150         }
151
152         try {
153             switch (channelId) {
154                 case CHANNEL_ZONE_RUN_CUSTOM:
155                     if (!(command instanceof QuantityType<?>)) {
156                         logger.warn("Invalid command type for run custom {}", command.getClass().getName());
157                         return;
158                     }
159                     QuantityType<?> time = ((QuantityType<?>) command).toUnit(Units.SECOND);
160
161                     if (time == null) {
162                         return;
163                     }
164
165                     if (allCommand) {
166                         client.runAllRelays(controllerId, time.intValue());
167                     } else if (zone != null) {
168                         client.runRelay(zone.id, time.intValue());
169                     }
170                     break;
171                 case CHANNEL_ZONE_RUN:
172                     if (!(command instanceof OnOffType)) {
173                         logger.warn("Invalid command type for run {}", command.getClass().getName());
174                         return;
175                     }
176                     if (allCommand) {
177                         if (command == OnOffType.ON) {
178                             client.runAllRelays(controllerId);
179                         } else {
180                             client.stopAllRelays(controllerId);
181                         }
182                     } else if (zone != null) {
183                         if (command == OnOffType.ON) {
184                             client.runRelay(zone.id);
185                         } else {
186                             client.stopRelay(zone.id);
187                         }
188                     }
189                     break;
190                 case CHANNEL_ZONE_SUSPEND:
191                     if (!(command instanceof OnOffType)) {
192                         logger.warn("Invalid command type for suspend {}", command.getClass().getName());
193                         return;
194                     }
195                     if (allCommand) {
196                         if (command == OnOffType.ON) {
197                             client.suspendAllRelays(controllerId, OffsetDateTime.now(ZoneOffset.UTC)
198                                     .plus(DEFAULT_SUSPEND_TIME_HOURS, ChronoUnit.HOURS).format(DATE_FORMATTER));
199                         } else {
200                             client.resumeAllRelays(controllerId);
201                         }
202                     } else if (zone != null) {
203                         if (command == OnOffType.ON) {
204                             client.suspendRelay(zone.id, OffsetDateTime.now(ZoneOffset.UTC)
205                                     .plus(DEFAULT_SUSPEND_TIME_HOURS, ChronoUnit.HOURS).format(DATE_FORMATTER));
206                         } else {
207                             client.resumeRelay(zone.id);
208                         }
209                     }
210                     break;
211                 case CHANNEL_ZONE_SUSPENDUNTIL:
212                     if (!(command instanceof DateTimeType)) {
213                         logger.warn("Invalid command type for suspend {}", command.getClass().getName());
214                         return;
215                     }
216                     if (allCommand) {
217                         client.suspendAllRelays(controllerId,
218                                 ((DateTimeType) command).getZonedDateTime().format(DATE_FORMATTER));
219                     } else if (zone != null) {
220                         client.suspendRelay(zone.id,
221                                 ((DateTimeType) command).getZonedDateTime().format(DATE_FORMATTER));
222                     }
223                     break;
224                 default:
225                     logger.warn("Uknown channelId {}", channelId);
226                     return;
227             }
228             HydrawiseAccountHandler handler = getAccountHandler();
229             if (handler != null) {
230                 handler.refreshData(DEFAULT_REFRESH_SECONDS);
231             }
232         } catch (HydrawiseCommandException | HydrawiseConnectionException e) {
233             logger.debug("Could not issue command", e);
234         } catch (HydrawiseAuthenticationException e) {
235             logger.debug("Credentials not valid");
236         }
237     }
238
239     @Override
240     public void onData(List<Controller> controllers) {
241         logger.trace("onData my controller id {}", controllerId);
242         controllers.stream().filter(c -> c.id == controllerId).findFirst().ifPresent(controller -> {
243             logger.trace("Updating Controller {} sensors {} forecast {} ", controller.id, controller.sensors,
244                     controller.location.forecast);
245             updateController(controller);
246             if (controller.sensors != null) {
247                 updateSensors(controller.sensors);
248             }
249             if (controller.location != null && controller.location.forecast != null) {
250                 updateForecast(controller.location.forecast);
251             }
252             if (controller.zones != null) {
253                 updateZones(controller.zones);
254             }
255
256             // update values with what the cloud tells us even though the controller may be offline
257             if (!controller.status.online) {
258                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
259                         "Service reports controller as offline");
260             } else if (getThing().getStatus() != ThingStatus.ONLINE) {
261                 updateStatus(ThingStatus.ONLINE);
262             }
263         });
264     }
265
266     @Override
267     public void channelLinked(ChannelUID channelUID) {
268         // clear our cached value so the new channel gets updated on the next poll
269         stateMap.remove(channelUID.getId());
270     }
271
272     private void updateController(Controller controller) {
273         updateGroupState(CHANNEL_GROUP_CONTROLLER_SYSTEM, CHANNEL_CONTROLLER_NAME, new StringType(controller.name));
274         updateGroupState(CHANNEL_GROUP_CONTROLLER_SYSTEM, CHANNEL_CONTROLLER_SUMMARY,
275                 new StringType(controller.status.summary));
276         updateGroupState(CHANNEL_GROUP_CONTROLLER_SYSTEM, CHANNEL_CONTROLLER_LAST_CONTACT,
277                 controller.status.lastContact != null ? secondsToDateTime(controller.status.lastContact.timestamp)
278                         : UnDefType.NULL);
279     }
280
281     private void updateZones(List<Zone> zones) {
282         AtomicReference<Boolean> anyRunning = new AtomicReference<>(false);
283         AtomicReference<Boolean> anySuspended = new AtomicReference<>(false);
284         for (Zone zone : zones) {
285             // there are 12 relays per expander, expanders will have a zoneNumber like:
286             // 10 for expander 0, relay 10 = zone10
287             // 101 for expander 1, relay 1 = zone13
288             // 212 for expander 2, relay 12 = zone36
289             // division of integers in Java give whole numbers, not remainders FYI
290             int zoneNumber = ((zone.number.value / 100) * 12) + (zone.number.value % 100);
291
292             String group = "zone" + zoneNumber;
293             zoneMaps.put(group, zone);
294             logger.trace("Updateing Zone {} {} ", group, zone.name);
295             updateGroupState(group, CHANNEL_ZONE_NAME, new StringType(zone.name));
296             updateGroupState(group, CHANNEL_ZONE_ICON, new StringType(BASE_IMAGE_URL + zone.icon.fileName));
297             if (zone.scheduledRuns != null) {
298                 updateGroupState(group, CHANNEL_ZONE_SUMMARY,
299                         zone.scheduledRuns.summary != null ? new StringType(zone.scheduledRuns.summary)
300                                 : UnDefType.UNDEF);
301                 ZoneRun nextRun = zone.scheduledRuns.nextRun;
302                 if (nextRun != null) {
303                     updateGroupState(group, CHANNEL_ZONE_DURATION, new QuantityType<>(nextRun.duration, Units.MINUTE));
304                     updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME,
305                             secondsToDateTime(nextRun.startTime.timestamp));
306                 } else {
307                     updateGroupState(group, CHANNEL_ZONE_DURATION, UnDefType.UNDEF);
308                     updateGroupState(group, CHANNEL_ZONE_NEXT_RUN_TIME_TIME, UnDefType.UNDEF);
309                 }
310                 ZoneRun currRunn = zone.scheduledRuns.currentRun;
311                 if (currRunn != null) {
312                     updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.ON);
313                     updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(
314                             currRunn.endTime.timestamp - Instant.now().getEpochSecond(), Units.SECOND));
315                     anyRunning.set(true);
316                 } else {
317                     updateGroupState(group, CHANNEL_ZONE_RUN, OnOffType.OFF);
318                     updateGroupState(group, CHANNEL_ZONE_TIME_LEFT, new QuantityType<>(0, Units.MINUTE));
319                 }
320             }
321             if (zone.status.suspendedUntil != null) {
322                 updateGroupState(group, CHANNEL_ZONE_SUSPEND, OnOffType.ON);
323                 updateGroupState(group, CHANNEL_ZONE_SUSPENDUNTIL,
324                         secondsToDateTime(zone.status.suspendedUntil.timestamp));
325                 anySuspended.set(true);
326             } else {
327                 updateGroupState(group, CHANNEL_ZONE_SUSPEND, OnOffType.OFF);
328                 updateGroupState(group, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
329             }
330         }
331         updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_RUN, OnOffType.from(anyRunning.get()));
332         updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPEND, OnOffType.from(anySuspended.get()));
333         updateGroupState(CHANNEL_GROUP_ALLZONES, CHANNEL_ZONE_SUSPENDUNTIL, UnDefType.UNDEF);
334     }
335
336     private void updateSensors(List<Sensor> sensors) {
337         int i = 1;
338         for (Sensor sensor : sensors) {
339             String group = "sensor" + (i++);
340             updateGroupState(group, CHANNEL_SENSOR_NAME, new StringType(sensor.name));
341             if (sensor.model.offTimer != null) {
342                 updateGroupState(group, CHANNEL_SENSOR_OFFTIMER,
343                         new QuantityType<>(sensor.model.offTimer, Units.SECOND));
344             }
345             if (sensor.model.delay != null) {
346                 updateGroupState(group, CHANNEL_SENSOR_DELAY, new QuantityType<>(sensor.model.delay, Units.SECOND));
347             }
348             if (sensor.model.offLevel != null) {
349                 updateGroupState(group, CHANNEL_SENSOR_OFFLEVEL, new DecimalType(sensor.model.offLevel));
350             }
351             if (sensor.status.active != null) {
352                 updateGroupState(group, CHANNEL_SENSOR_ACTIVE, OnOffType.from(sensor.status.active));
353             }
354             if (sensor.status.waterFlow != null) {
355                 updateGroupState(group, CHANNEL_SENSOR_WATERFLOW,
356                         waterFlowToQuantityType(sensor.status.waterFlow.value, sensor.status.waterFlow.unit));
357             }
358         }
359     }
360
361     private void updateForecast(List<Forecast> forecasts) {
362         int i = 1;
363         for (Forecast forecast : forecasts) {
364             String group = "forecast" + (i++);
365             updateGroupState(group, CHANNEL_FORECAST_TIME, stringToDateTime(forecast.time));
366             updateGroupState(group, CHANNEL_FORECAST_CONDITIONS, new StringType(forecast.conditions));
367             updateGroupState(group, CHANNEL_FORECAST_HUMIDITY, new DecimalType(forecast.averageHumidity.intValue()));
368             updateTemperature(forecast.highTemperature, group, CHANNEL_FORECAST_TEMPERATURE_HIGH);
369             updateTemperature(forecast.lowTemperature, group, CHANNEL_FORECAST_TEMPERATURE_LOW);
370             updateWindspeed(forecast.averageWindSpeed, group, CHANNEL_FORECAST_WIND);
371             // this seems to sometimes be optional
372             if (forecast.evapotranspiration != null) {
373                 updateGroupState(group, CHANNEL_FORECAST_EVAPOTRANSPRIATION,
374                         new DecimalType(forecast.evapotranspiration.value.floatValue()));
375             }
376             updateGroupState(group, CHANNEL_FORECAST_PRECIPITATION,
377                     new DecimalType(forecast.precipitation.value.floatValue()));
378             updateGroupState(group, CHANNEL_FORECAST_PROBABILITYOFPRECIPITATION,
379                     new DecimalType(forecast.probabilityOfPrecipitation));
380
381         }
382     }
383
384     private void updateTemperature(UnitValue temperature, String group, String channel) {
385         logger.debug("TEMP {} {} {} {}", group, channel, temperature.unit, temperature.value);
386         updateGroupState(group, channel, new QuantityType<>(temperature.value,
387                 "\\u00b0F".equals(temperature.unit) ? ImperialUnits.FAHRENHEIT : SIUnits.CELSIUS));
388     }
389
390     private void updateWindspeed(UnitValue wind, String group, String channel) {
391         updateGroupState(group, channel, new QuantityType<>(wind.value,
392                 "mph".equals(wind.unit) ? ImperialUnits.MILES_PER_HOUR : SIUnits.KILOMETRE_PER_HOUR));
393     }
394
395     private void updateGroupState(String group, String channelID, State state) {
396         String channelName = group + "#" + channelID;
397         State oldState = stateMap.put(channelName, state);
398         if (!state.equals(oldState)) {
399             ChannelUID channelUID = new ChannelUID(this.getThing().getUID(), channelName);
400             logger.debug("updateState updating {} {}", channelUID, state);
401             updateState(channelUID, state);
402         }
403     }
404
405     @Nullable
406     private HydrawiseAccountHandler getAccountHandler() {
407         Bridge bridge = getBridge();
408         if (bridge == null) {
409             logger.warn("No bridge found for thing");
410             return null;
411         }
412         BridgeHandler handler = bridge.getHandler();
413         if (handler == null) {
414             logger.warn("No handler found for bridge");
415             return null;
416         }
417         return ((HydrawiseAccountHandler) handler);
418     }
419
420     @Nullable
421     private HydrawiseGraphQLClient apiClient() {
422         HydrawiseAccountHandler handler = getAccountHandler();
423         if (handler == null) {
424             return null;
425         } else {
426             return handler.graphQLClient();
427         }
428     }
429
430     private DateTimeType secondsToDateTime(Integer seconds) {
431         Instant instant = Instant.ofEpochSecond(seconds);
432         ZonedDateTime zdt = ZonedDateTime.ofInstant(instant, ZoneOffset.UTC);
433         return new DateTimeType(zdt);
434     }
435
436     private DateTimeType stringToDateTime(String date) {
437         ZonedDateTime zdt = ZonedDateTime.parse(date, DATE_FORMATTER);
438         return new DateTimeType(zdt);
439     }
440
441     private QuantityType<Volume> waterFlowToQuantityType(Number flow, String units) {
442         double waterFlow = flow.doubleValue();
443         if ("gals".equals(units)) {
444             waterFlow = waterFlow * 3.785;
445         }
446         return new QuantityType<>(waterFlow, Units.LITRE);
447     }
448 }