]> git.basschouten.com Git - openhab-addons.git/blob
6f758aaef8f25767f289ae5ece038473a0d3cc4a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.iaqualink.internal.handler;
14
15 import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
16 import static org.openhab.core.library.unit.SIUnits.CELSIUS;
17
18 import java.io.IOException;
19 import java.math.BigDecimal;
20 import java.math.RoundingMode;
21 import java.util.ArrayList;
22 import java.util.Arrays;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.Objects;
28 import java.util.Optional;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
31
32 import javax.measure.Unit;
33 import javax.measure.quantity.Temperature;
34
35 import org.apache.commons.lang.StringUtils;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.openhab.binding.iaqualink.internal.IAqualinkBindingConstants;
40 import org.openhab.binding.iaqualink.internal.api.IAqualinkClient;
41 import org.openhab.binding.iaqualink.internal.api.IAqualinkClient.NotAuthorizedException;
42 import org.openhab.binding.iaqualink.internal.api.model.AccountInfo;
43 import org.openhab.binding.iaqualink.internal.api.model.Auxiliary;
44 import org.openhab.binding.iaqualink.internal.api.model.Device;
45 import org.openhab.binding.iaqualink.internal.api.model.Home;
46 import org.openhab.binding.iaqualink.internal.api.model.OneTouch;
47 import org.openhab.binding.iaqualink.internal.config.IAqualinkConfiguration;
48 import org.openhab.core.library.types.DecimalType;
49 import org.openhab.core.library.types.OnOffType;
50 import org.openhab.core.library.types.PercentType;
51 import org.openhab.core.library.types.QuantityType;
52 import org.openhab.core.library.types.StringType;
53 import org.openhab.core.thing.Channel;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.binding.BaseThingHandler;
59 import org.openhab.core.thing.binding.builder.ChannelBuilder;
60 import org.openhab.core.thing.binding.builder.ThingBuilder;
61 import org.openhab.core.thing.type.ChannelTypeUID;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.openhab.core.types.State;
65 import org.openhab.core.types.UnDefType;
66 import org.slf4j.Logger;
67 import org.slf4j.LoggerFactory;
68
69 /**
70  *
71  * iAquaLink Control Binding
72  *
73  * iAquaLink controllers allow remote access to Jandy/Zodiac pool systems. This
74  * binding allows openHAB to both monitor and control a pool system through
75  * these controllers.
76  *
77  * The {@link IAqualinkHandler} is responsible for handling commands, which
78  * are sent to one of the channels.
79  *
80  * @author Dan Cunningham - Initial contribution
81  */
82 @NonNullByDefault
83 public class IAqualinkHandler extends BaseThingHandler {
84
85     private final Logger logger = LoggerFactory.getLogger(IAqualinkHandler.class);
86
87     /**
88      * Minimum amount of time we can poll for updates
89      */
90     private static final int MIN_REFRESH_SECONDS = 5;
91
92     /**
93      * Minimum amount of time we can poll after a command
94      */
95     private static final int COMMAND_REFRESH_SECONDS = 5;
96
97     /**
98      * Default iAqulink key used by existing clients in the marketplace
99      */
100     private static final String DEFAULT_API_KEY = "EOOEMOW4YR6QNB07";
101
102     /**
103      * Local cache of iAqualink states
104      */
105     private Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
106
107     /**
108      * Our poll rate
109      */
110     private int refresh;
111
112     /**
113      * fixed API key provided by iAqualink clients (Android, IOS), unknown if this will change in the future.
114      */
115
116     private @Nullable String apiKey;
117
118     /**
119      * Optional serial number of the pool controller to connect to, only useful if you have more then one controller
120      */
121     private @Nullable String serialNumber;
122
123     /**
124      * Server issued sessionId
125      */
126     private @Nullable String sessionId;
127
128     /**
129      * When we first connect we will dynamically create channels based on what the controller is configured for
130      */
131     private boolean firstRun;
132
133     /**
134      * Future to poll for updated
135      */
136     private @Nullable ScheduledFuture<?> pollFuture;
137
138     /**
139      * The client interface to the iAqualink Service
140      */
141     private IAqualinkClient client;
142
143     /**
144      * Temperature unit, will be set based on user setting
145      */
146     private Unit<Temperature> temperatureUnit = CELSIUS;
147
148     /**
149      * Constructs a new {@link IAqualinkHandler}
150      *
151      * @param thing
152      * @param httpClient
153      */
154     public IAqualinkHandler(Thing thing, HttpClient httpClient) {
155         super(thing);
156         client = new IAqualinkClient(httpClient);
157     }
158
159     @Override
160     public void initialize() {
161         // don't hold up initialize
162         scheduler.schedule(this::configure, 0, TimeUnit.SECONDS);
163     }
164
165     @Override
166     public void dispose() {
167         logger.debug("Handler disposed.");
168         clearPolling();
169     }
170
171     @Override
172     public void channelLinked(ChannelUID channelUID) {
173         // clear our cached value so the new channel gets updated on the next poll
174         stateMap.remove(channelUID.getAsString());
175     }
176
177     @Override
178     public void handleCommand(ChannelUID channelUID, Command command) {
179         logger.debug("handleCommand channel: {} command: {}", channelUID, command);
180
181         if (getThing().getStatus() != ThingStatus.ONLINE) {
182             logger.warn("Controller is not ONLINE and is not responding to commands");
183             return;
184         }
185
186         clearPolling();
187
188         String channelName = channelUID.getIdWithoutGroup();
189         // remove the current state to ensure we send an update
190         stateMap.remove(channelUID.getAsString());
191         try {
192             if (command instanceof RefreshType) {
193                 logger.debug("Channel {} state has been cleared", channelName);
194             } else if (channelName.startsWith("aux_")) {
195                 // Auxiliary Commands
196                 String auxId = channelName.replaceFirst("aux_", "");
197                 if (command instanceof PercentType) {
198                     client.dimmerCommand(serialNumber, sessionId, auxId, command.toString());
199                 } else if (command instanceof StringType) {
200                     String cmd = "off".equals(command.toString()) ? "0"
201                             : "on".equals(command.toString()) ? "1" : command.toString();
202                     client.lightCommand(serialNumber, sessionId, auxId, cmd,
203                             AuxiliaryType.fromChannelTypeUID(getChannelTypeUID(channelUID)).getSubType());
204                 } else if (command instanceof OnOffType) {
205                     // these are toggle commands and require we have the current state to turn on/off
206                     Auxiliary[] auxs = client.getAux(serialNumber, sessionId);
207                     Optional<Auxiliary> optional = Arrays.stream(auxs).filter(o -> o.getName().equals(channelName))
208                             .findFirst();
209                     if (optional.isPresent()) {
210                         if (toState(channelName, "Switch", optional.get().getState()) != command) {
211                             client.auxSetCommand(serialNumber, sessionId, channelName);
212                         }
213                     }
214                 }
215             } else if (channelName.endsWith("_set_point")) {
216                 // Set Point Commands
217                 if ("spa_set_point".equals(channelName)) {
218                     BigDecimal value = commandToRoundedTemperature(command, temperatureUnit);
219                     if (value != null) {
220                         client.setSpaTemp(serialNumber, sessionId, value.floatValue());
221                     }
222                 } else if ("pool_set_point".equals(channelName)) {
223                     BigDecimal value = commandToRoundedTemperature(command, temperatureUnit);
224                     if (value != null) {
225                         client.setPoolTemp(serialNumber, sessionId, value.floatValue());
226                     }
227                 }
228             } else if (command instanceof OnOffType) {
229                 // these are toggle commands and require we have the current state to turn on/off
230                 if (channelName.startsWith("onetouch_")) {
231                     OneTouch[] ota = client.getOneTouch(serialNumber, sessionId);
232                     Optional<OneTouch> optional = Arrays.stream(ota).filter(o -> o.getName().equals(channelName))
233                             .findFirst();
234                     if (optional.isPresent()) {
235                         if (toState(channelName, "Switch", optional.get().getState()) != command) {
236                             logger.debug("Sending command {} to {}", command, channelName);
237                             client.oneTouchSetCommand(serialNumber, sessionId, channelName);
238                         }
239                     }
240                 } else if (channelName.endsWith("heater") || channelName.endsWith("pump")) {
241                     String value = client.getHome(serialNumber, sessionId).getSerializedMap().get(channelName);
242                     if (toState(channelName, "Switch", value) != command) {
243                         logger.debug("Sending command {} to {}", command, channelName);
244                         client.homeScreenSetCommand(serialNumber, sessionId, channelName);
245                     }
246                 }
247             }
248             initPolling(COMMAND_REFRESH_SECONDS);
249         } catch (IOException e) {
250             logger.debug("Exception executing command", e);
251             initPolling(COMMAND_REFRESH_SECONDS);
252         } catch (NotAuthorizedException e) {
253             logger.debug("Authorization Exception sending command", e);
254             configure();
255         }
256     }
257
258     /**
259      * Configures this thing
260      */
261     private void configure() {
262         clearPolling();
263         firstRun = true;
264
265         IAqualinkConfiguration configuration = getConfig().as(IAqualinkConfiguration.class);
266         String username = configuration.userName;
267         String password = configuration.password;
268         String confSerialId = configuration.serialId;
269         String confApiKey = configuration.apiKey;
270
271         if (StringUtils.isNotBlank(confApiKey)) {
272             this.apiKey = confApiKey;
273         } else {
274             this.apiKey = DEFAULT_API_KEY;
275         }
276
277         this.refresh = Math.max(configuration.refresh, MIN_REFRESH_SECONDS);
278
279         try {
280             AccountInfo accountInfo = client.login(username, password, apiKey);
281             sessionId = accountInfo.getSessionId();
282             if (sessionId == null) {
283                 throw new IOException("Response from controller not valid");
284             }
285             logger.debug("SessionID {}", sessionId);
286
287             Device[] devices = client.getDevices(apiKey, accountInfo.getAuthenticationToken(), accountInfo.getId());
288
289             if (devices.length == 0) {
290                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No registered devices found");
291                 return;
292             }
293
294             if (StringUtils.isNotBlank(confSerialId)) {
295                 serialNumber = confSerialId.replaceAll("[^a-zA-Z0-9]", "").toLowerCase();
296                 if (!Arrays.stream(devices).anyMatch(device -> device.getSerialNumber().equals(serialNumber))) {
297                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
298                             "No Device for given serialId found");
299                     return;
300                 }
301             } else {
302                 serialNumber = devices[0].getSerialNumber();
303             }
304
305             initPolling(COMMAND_REFRESH_SECONDS);
306         } catch (IOException e) {
307             logger.debug("Could not connect to service {}", e.getMessage());
308             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
309         } catch (NotAuthorizedException e) {
310             logger.debug("Credentials not valid");
311             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Credentials not valid");
312         }
313     }
314
315     /**
316      * Starts/Restarts polling with an initial delay. This allows changes in the poll cycle for when commands are sent
317      * and we need to poll sooner then the next refresh cycle.
318      */
319     private synchronized void initPolling(int initialDelay) {
320         clearPolling();
321         pollFuture = scheduler.scheduleWithFixedDelay(this::pollController, initialDelay, refresh, TimeUnit.SECONDS);
322     }
323
324     /**
325      * Stops/clears this thing's polling future
326      */
327     private void clearPolling() {
328         ScheduledFuture<?> localFuture = pollFuture;
329         if (isFutureValid(localFuture)) {
330             if (localFuture != null) {
331                 localFuture.cancel(true);
332             }
333         }
334     }
335
336     private boolean isFutureValid(@Nullable ScheduledFuture<?> future) {
337         return future != null && !future.isCancelled();
338     }
339
340     /**
341      * Poll the controller for updates.
342      */
343     private void pollController() {
344         ScheduledFuture<?> localFuture = pollFuture;
345         try {
346             Home home = client.getHome(serialNumber, sessionId);
347
348             if ("Error".equals(home.getResponse())) {
349                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
350                         "Service reports controller status as: " + home.getStatus());
351                 return;
352             }
353
354             Map<String, String> map = home.getSerializedMap();
355             if (map != null) {
356                 temperatureUnit = "F".equalsIgnoreCase(map.get("temp_scale")) ? FAHRENHEIT : CELSIUS;
357                 map.forEach((k, v) -> {
358                     updatedState(k, v);
359                     if (k.endsWith("_heater")) {
360                         HeaterState hs = HeaterState.fromValue(v);
361                         updatedState(k + "_status", hs == null ? null : hs.getLabel());
362                     }
363                 });
364             }
365
366             OneTouch[] oneTouches = client.getOneTouch(serialNumber, sessionId);
367             Auxiliary[] auxes = client.getAux(serialNumber, sessionId);
368
369             if (firstRun) {
370                 firstRun = false;
371                 updateChannels(auxes, oneTouches);
372             }
373
374             for (OneTouch ot : oneTouches) {
375                 updatedState(ot.getName(), ot.getState());
376             }
377
378             for (Auxiliary aux : auxes) {
379                 switch (aux.getType()) {
380                     // dimmer uses subType for value
381                     case "1":
382                         updatedState(aux.getName(), aux.getSubtype());
383                         break;
384                     // Color lights do not report the color value, only on/off
385                     case "2":
386                         updatedState(aux.getName(), "0".equals(aux.getState()) ? "off" : "on");
387                         break;
388                     // all else are switches
389                     default:
390                         updatedState(aux.getName(), aux.getState());
391                 }
392             }
393
394             if (getThing().getStatus() != ThingStatus.ONLINE) {
395                 updateStatus(ThingStatus.ONLINE);
396             }
397         } catch (IOException e) {
398             // poller will continue to run, set offline until next run
399             logger.debug("Exception polling", e);
400             if (isFutureValid(localFuture)) {
401                 // only valid futures should set state, otherwise this exception was do to being canceled.
402                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
403             }
404         } catch (NotAuthorizedException e) {
405             // if are creds are not valid, we need to try re authorizing again
406             logger.debug("Authorization Exception during polling", e);
407             clearPolling();
408             configure();
409         }
410     }
411
412     /**
413      * Update an channels state only if the value of the channel has changed since our last poll.
414      *
415      * @param name
416      * @param value
417      */
418     private void updatedState(String name, @Nullable String value) {
419         logger.trace("updatedState {} : {}", name, value);
420         Channel channel = getThing().getChannel(name);
421         if (channel != null) {
422             State state = toState(name, channel.getAcceptedItemType(), value);
423             State oldState = stateMap.put(channel.getUID().getAsString(), state);
424             if (!state.equals(oldState)) {
425                 logger.trace("updating channel {} with state {} (old state {})", channel.getUID(), state, oldState);
426                 updateState(channel.getUID(), state);
427             }
428         }
429     }
430
431     /**
432      * Converts a {@link String} value to a {@link State} for a given
433      * {@link String} accepted type
434      *
435      * @param itemType
436      * @param value
437      * @return {@link State}
438      */
439     private State toState(String name, @Nullable String type, @Nullable String value) {
440         try {
441             // @nullable checker does not recognize isBlank as checking null here, so must use == null to make happy
442             if (value == null || StringUtils.isBlank(value)) {
443                 return UnDefType.UNDEF;
444             }
445
446             if (type == null) {
447                 return StringType.valueOf(value);
448             }
449
450             switch (type) {
451                 case "Number:Temperature":
452                     return new QuantityType<>(Float.parseFloat(value), temperatureUnit);
453                 case "Number":
454                     return new DecimalType(value);
455                 case "Dimmer":
456                     return new PercentType(value);
457                 case "Switch":
458                     return Integer.parseInt(value) > 0 ? OnOffType.ON : OnOffType.OFF;
459                 default:
460                     return StringType.valueOf(value);
461             }
462         } catch (NumberFormatException e) {
463             return UnDefType.UNDEF;
464         }
465     }
466
467     /**
468      * Creates channels based on what is supported by the controller.
469      */
470     private void updateChannels(Auxiliary[] auxes, OneTouch[] oneTouches) {
471         List<Channel> channels = new ArrayList<>(getThing().getChannels());
472         for (Auxiliary aux : auxes) {
473             ChannelUID channelUID = new ChannelUID(getThing().getUID(), aux.getName());
474             logger.debug("Add channel Aux Name: {} Label: {} Type: {} Subtype: {}", aux.getName(), aux.getLabel(),
475                     aux.getType(), aux.getSubtype());
476             switch (aux.getType()) {
477                 case "1":
478                     addNewChannelToList(channels, channelUID, "Dimmer",
479                             IAqualinkBindingConstants.CHANNEL_TYPE_UID_AUX_DIMMER, aux.getLabel());
480                     break;
481                 case "2": {
482                     addNewChannelToList(channels, channelUID, "String",
483                             AuxiliaryType.fromSubType(aux.getSubtype()).getChannelTypeUID(), aux.getLabel());
484                 }
485                     break;
486                 default:
487                     addNewChannelToList(channels, channelUID, "Switch",
488                             IAqualinkBindingConstants.CHANNEL_TYPE_UID_AUX_SWITCH, aux.getLabel());
489             }
490         }
491
492         for (OneTouch oneTouch : oneTouches) {
493             if ("0".equals(oneTouch.getStatus())) {
494                 // OneTouch is not enabled
495                 continue;
496             }
497
498             ChannelUID channelUID = new ChannelUID(getThing().getUID(), oneTouch.getName());
499             addNewChannelToList(channels, channelUID, "Switch", IAqualinkBindingConstants.CHANNEL_TYPE_UID_ONETOUCH,
500                     oneTouch.getLabel());
501         }
502
503         ThingBuilder thingBuilder = editThing();
504         thingBuilder.withChannels(channels);
505         updateThing(thingBuilder.build());
506     }
507
508     /**
509      * Adds a channel to the list of channels if the channel does not exist or is of a different type
510      *
511      */
512     private void addNewChannelToList(List<Channel> list, ChannelUID channelUID, String itemType,
513             ChannelTypeUID channelType, String label) {
514         // if there is no entry, add it
515         if (!list.stream().anyMatch(c -> c.getUID().equals(channelUID))) {
516             list.add(ChannelBuilder.create(channelUID, itemType).withType(channelType).withLabel(label).build());
517         } else if (list.removeIf(c -> c.getUID().equals(channelUID) && !channelType.equals(c.getChannelTypeUID()))) {
518             // this channel uid exists, but has a different type so remove and add our new one
519             list.add(ChannelBuilder.create(channelUID, itemType).withType(channelType).withLabel(label).build());
520         }
521     }
522
523     /**
524      * inspired by the openHAB Nest thermostat binding
525      */
526     @SuppressWarnings("unchecked")
527     private @Nullable BigDecimal commandToRoundedTemperature(Command command, Unit<Temperature> unit)
528             throws IllegalArgumentException {
529         QuantityType<Temperature> quantity;
530         if (command instanceof QuantityType) {
531             quantity = (QuantityType<Temperature>) command;
532         } else {
533             quantity = new QuantityType<>(new BigDecimal(command.toString()), unit);
534         }
535
536         QuantityType<Temperature> temparatureQuantity = quantity.toUnit(unit);
537         if (temparatureQuantity == null) {
538             return null;
539         }
540
541         BigDecimal value = temparatureQuantity.toBigDecimal();
542         BigDecimal increment = CELSIUS == unit ? new BigDecimal("0.5") : new BigDecimal("1");
543         BigDecimal divisor = value.divide(increment, 0, RoundingMode.HALF_UP);
544         return divisor.multiply(increment);
545     }
546
547     private ChannelTypeUID getChannelTypeUID(ChannelUID channelUID) {
548         Channel channel = getThing().getChannel(channelUID.getId());
549         Objects.requireNonNull(channel);
550         ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
551         Objects.requireNonNull(channelTypeUID);
552         return channelTypeUID;
553     }
554 }