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