2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.iaqualink.internal.handler;
15 import static org.openhab.core.library.unit.ImperialUnits.FAHRENHEIT;
16 import static org.openhab.core.library.unit.SIUnits.CELSIUS;
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;
27 import java.util.Objects;
28 import java.util.Optional;
29 import java.util.concurrent.ScheduledFuture;
30 import java.util.concurrent.TimeUnit;
32 import javax.measure.Unit;
33 import javax.measure.quantity.Temperature;
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;
70 * iAquaLink Control Binding
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
76 * The {@link IAqualinkHandler} is responsible for handling commands, which
77 * are sent to one of the channels.
79 * @author Dan Cunningham - Initial contribution
82 public class IAqualinkHandler extends BaseThingHandler {
84 private final Logger logger = LoggerFactory.getLogger(IAqualinkHandler.class);
87 * Minimum amount of time we can poll for updates
89 private static final int MIN_REFRESH_SECONDS = 5;
92 * Minimum amount of time we can poll after a command
94 private static final int COMMAND_REFRESH_SECONDS = 5;
97 * Default iAqulink key used by existing clients in the marketplace
99 private static final String DEFAULT_API_KEY = "EOOEMOW4YR6QNB07";
102 * Local cache of iAqualink states
104 private Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
112 * fixed API key provided by iAqualink clients (Android, IOS), unknown if this will change in the future.
115 private @Nullable String apiKey;
118 * Optional serial number of the pool controller to connect to, only useful if you have more then one controller
120 private @Nullable String serialNumber;
123 * Server issued sessionId
125 private @Nullable String sessionId;
128 * When we first connect we will dynamically create channels based on what the controller is configured for
130 private boolean firstRun;
133 * Future to poll for updated
135 private @Nullable ScheduledFuture<?> pollFuture;
138 * The client interface to the iAqualink Service
140 private IAqualinkClient client;
143 * Temperature unit, will be set based on user setting
145 private Unit<Temperature> temperatureUnit = CELSIUS;
148 * Constructs a new {@link IAqualinkHandler}
153 public IAqualinkHandler(Thing thing, HttpClient httpClient) {
155 client = new IAqualinkClient(httpClient);
159 public void initialize() {
160 // don't hold up initialize
161 scheduler.schedule(this::configure, 0, TimeUnit.SECONDS);
165 public void dispose() {
166 logger.debug("Handler disposed.");
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());
177 public void handleCommand(ChannelUID channelUID, Command command) {
178 logger.debug("handleCommand channel: {} command: {}", channelUID, command);
180 if (getThing().getStatus() != ThingStatus.ONLINE) {
181 logger.warn("Controller is not ONLINE and is not responding to commands");
187 String channelName = channelUID.getIdWithoutGroup();
188 // remove the current state to ensure we send an update
189 stateMap.remove(channelUID.getAsString());
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))
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);
216 } else if (channelName.endsWith("_set_point")) {
217 // Set Point Commands
218 if ("spa_set_point".equals(channelName)) {
219 BigDecimal value = commandToRoundedTemperature(command, temperatureUnit);
221 client.setSpaTemp(serialNumber, sessionId, value.floatValue());
223 } else if ("pool_set_point".equals(channelName)) {
224 BigDecimal value = commandToRoundedTemperature(command, temperatureUnit);
226 client.setPoolTemp(serialNumber, sessionId, value.floatValue());
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))
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);
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);
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);
263 * Configures this thing
265 private void configure() {
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;
275 if (confApiKey != null && !confApiKey.isBlank()) {
276 this.apiKey = confApiKey;
278 this.apiKey = DEFAULT_API_KEY;
281 this.refresh = Math.max(configuration.refresh, MIN_REFRESH_SECONDS);
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");
289 logger.debug("SessionID {}", sessionId);
291 Device[] devices = client.getDevices(apiKey, accountInfo.getAuthenticationToken(), accountInfo.getId());
293 if (devices.length == 0) {
294 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "No registered devices found");
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");
306 serialNumber = devices[0].getSerialNumber();
309 logger.debug("Using serial number {}", serialNumber);
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");
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.
325 private synchronized void initPolling(int initialDelay) {
327 pollFuture = scheduler.scheduleWithFixedDelay(this::pollController, initialDelay, refresh, TimeUnit.SECONDS);
331 * Stops/clears this thing's polling future
333 private void clearPolling() {
334 ScheduledFuture<?> localFuture = pollFuture;
335 if (isFutureValid(localFuture)) {
336 if (localFuture != null) {
337 localFuture.cancel(true);
342 private boolean isFutureValid(@Nullable ScheduledFuture<?> future) {
343 return future != null && !future.isCancelled();
347 * Poll the controller for updates.
349 private void pollController() {
350 ScheduledFuture<?> localFuture = pollFuture;
352 Home home = client.getHome(serialNumber, sessionId);
354 if ("Error".equals(home.getResponse())) {
355 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
356 "Service reports controller status as: " + home.getStatus());
360 Map<String, String> map = home.getSerializedMap();
362 temperatureUnit = "F".equalsIgnoreCase(map.get("temp_scale")) ? FAHRENHEIT : CELSIUS;
363 map.forEach((k, v) -> {
365 if (k.endsWith("_heater")) {
366 HeaterState hs = HeaterState.fromValue(v);
367 updatedState(k + "_status", hs == null ? null : hs.getLabel());
372 OneTouch[] oneTouches = client.getOneTouch(serialNumber, sessionId);
373 Auxiliary[] auxes = client.getAux(serialNumber, sessionId);
377 updateChannels(auxes, oneTouches);
380 for (OneTouch ot : oneTouches) {
381 updatedState(ot.getName(), ot.getState());
384 for (Auxiliary aux : auxes) {
385 switch (aux.getType()) {
386 // dimmer uses subType for value
388 updatedState(aux.getName(), aux.getSubtype());
390 // Color lights do not report the color value, only on/off
392 updatedState(aux.getName(), "0".equals(aux.getState()) ? "off" : "on");
394 // all else are switches
396 updatedState(aux.getName(), aux.getState());
400 if (getThing().getStatus() != ThingStatus.ONLINE) {
401 updateStatus(ThingStatus.ONLINE);
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());
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);
419 * Update a channel state only if the value of the channel has changed since our last poll.
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);
438 * Converts a {@link String} value to a {@link State} for a given
439 * {@link String} accepted type
443 * @return {@link State}
445 private State toState(String name, @Nullable String type, @Nullable String value) {
447 if (value == null || value.isBlank()) {
448 return UnDefType.UNDEF;
452 return StringType.valueOf(value);
456 case "Number:Temperature":
457 return new QuantityType<>(Float.parseFloat(value), temperatureUnit);
459 return new DecimalType(value);
461 return new PercentType(value);
463 return Integer.parseInt(value) > 0 ? OnOffType.ON : OnOffType.OFF;
465 return StringType.valueOf(value);
467 } catch (IllegalArgumentException e) {
468 return UnDefType.UNDEF;
473 * Creates channels based on what is supported by the controller.
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()) {
483 addNewChannelToList(channels, channelUID, "Dimmer",
484 IAqualinkBindingConstants.CHANNEL_TYPE_UID_AUX_DIMMER, aux.getLabel());
487 addNewChannelToList(channels, channelUID, "String",
488 AuxiliaryType.fromSubType(aux.getSubtype()).getChannelTypeUID(), aux.getLabel());
492 addNewChannelToList(channels, channelUID, "Switch",
493 IAqualinkBindingConstants.CHANNEL_TYPE_UID_AUX_SWITCH, aux.getLabel());
497 for (OneTouch oneTouch : oneTouches) {
498 if ("0".equals(oneTouch.getStatus())) {
499 // OneTouch is not enabled
503 ChannelUID channelUID = new ChannelUID(getThing().getUID(), oneTouch.getName());
504 addNewChannelToList(channels, channelUID, "Switch", IAqualinkBindingConstants.CHANNEL_TYPE_UID_ONETOUCH,
505 oneTouch.getLabel());
508 ThingBuilder thingBuilder = editThing();
509 thingBuilder.withChannels(channels);
510 updateThing(thingBuilder.build());
514 * Adds a channel to the list of channels if the channel does not exist or is of a different type
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());
529 * inspired by the openHAB Nest thermostat binding
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;
538 quantity = new QuantityType<>(new BigDecimal(command.toString()), unit);
541 QuantityType<Temperature> temparatureQuantity = quantity.toUnit(unit);
542 if (temparatureQuantity == null) {
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);
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;