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.autelis.internal.handler;
15 import java.io.StringReader;
16 import java.nio.charset.StandardCharsets;
17 import java.util.Base64;
18 import java.util.Collections;
19 import java.util.HashMap;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.ScheduledFuture;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
25 import java.util.regex.Matcher;
26 import java.util.regex.Pattern;
28 import javax.xml.xpath.XPath;
29 import javax.xml.xpath.XPathExpressionException;
30 import javax.xml.xpath.XPathFactory;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.http.HttpHeader;
36 import org.eclipse.jetty.http.HttpStatus;
37 import org.openhab.binding.autelis.internal.AutelisBindingConstants;
38 import org.openhab.binding.autelis.internal.config.AutelisConfiguration;
39 import org.openhab.core.library.types.DecimalType;
40 import org.openhab.core.library.types.IncreaseDecreaseType;
41 import org.openhab.core.library.types.OnOffType;
42 import org.openhab.core.library.types.StringType;
43 import org.openhab.core.thing.Channel;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.types.Command;
50 import org.openhab.core.types.State;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53 import org.xml.sax.InputSource;
57 * Autelis Pool Control Binding
59 * Autelis controllers allow remote access to many common pool systems. This
60 * binding allows openHAB to both monitor and control a pool system through
63 * @see <a href="http://Autelis.com">http://autelis.com</a>
64 * @see <a href="http://www.autelis.com/wiki/index.php?title=Pool_Control_HTTP_Command_Reference"</a> for Jandy API
65 * @see <a href="http://www.autelis.com/wiki/index.php?title=Pool_Control_(PI)_HTTP_Command_Reference"</a> for Pentair
68 * The {@link AutelisHandler} is responsible for handling commands, which
69 * are sent to one of the channels.
71 * @author Dan Cunningham - Initial contribution
72 * @author Svilen Valkanov - Replaced Apache HttpClient with Jetty
74 public class AutelisHandler extends BaseThingHandler {
76 private final Logger logger = LoggerFactory.getLogger(AutelisHandler.class);
79 * Default timeout for http connections to an Autelis controller
81 static final int TIMEOUT_SECONDS = 5;
84 * Autelis controllers will not update their XML immediately after we change
85 * a value. To compensate we cache previous values for a {@link Channel}
86 * using the item name as a key. After a polling run has been executed we
87 * only update a channel if the value is different then what's in the
88 * cache. This cache is cleared after a fixed time period when commands are
91 private Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
94 * Clear our state every hour
96 private static final int NORMAL_CLEARTIME_SECONDS = 60 * 60;
99 * Default poll rate rate, this is derived from the Autelis web UI
101 private static final int DEFAULT_REFRESH_SECONDS = 3;
104 * How long should we wait to poll after we send an update, derived from trial and error
106 private static final int COMMAND_UPDATE_TIME_SECONDS = 6;
109 * The autelis unit will 'loose' commands if sent to fast
111 private static final int THROTTLE_TIME_MILLISECONDS = 500;
116 private static final int WEB_PORT = 80;
119 * Pentair values for pump response
121 private static final String[] PUMP_TYPES = { "watts", "rpm", "gpm", "filer", "error" };
124 * Matcher for pump channel names for Pentair
126 private static final Pattern PUMPS_PATTERN = Pattern.compile("(pumps/pump\\d?)-(watts|rpm|gpm|filter|error)");
129 * Holds the next clear time in millis
131 private long clearTime;
134 * Constructed URL consisting of host and port
136 private String baseURL;
144 * The http client used for polling requests
146 private HttpClient client = new HttpClient();
149 * last time we finished a request
151 private long lastRequestTime = 0;
154 * Authentication for login
156 private String basicAuthentication;
159 * Regex expression to match XML responses from the Autelis, this is used to
160 * combine similar XML docs into a single document, {@link XPath} is still
161 * used for XML querying
163 private Pattern responsePattern = Pattern.compile("<response>(.+?)</response>", Pattern.DOTALL);
166 * Future to poll for updated
168 private ScheduledFuture<?> pollFuture;
170 public AutelisHandler(Thing thing) {
175 public void initialize() {
176 startHttpClient(client);
181 public void dispose() {
182 logger.debug("Handler disposed.");
184 stopHttpClient(client);
188 public void channelLinked(ChannelUID channelUID) {
189 // clear our cached values so the new channel gets updated
194 public void handleCommand(ChannelUID channelUID, Command command) {
196 logger.debug("handleCommand channel: {} command: {}", channelUID.getId(), command);
197 if (AutelisBindingConstants.CMD_LIGHTS.equals(channelUID.getId())) {
199 * lighting command possible values, but we will let anything
200 * through. alloff, allon, csync, cset, cswim, party, romance,
201 * caribbean, american, sunset, royalty, blue, green, red, white,
202 * magenta, hold, recall
204 getUrl(baseURL + "/lights.cgi?val=" + command.toString(), TIMEOUT_SECONDS);
205 } else if (AutelisBindingConstants.CMD_REBOOT.equals(channelUID.getId()) && command == OnOffType.ON) {
206 getUrl(baseURL + "/userreboot.cgi?do=true" + command.toString(), TIMEOUT_SECONDS);
207 updateState(channelUID, OnOffType.OFF);
209 String[] args = channelUID.getId().split("-");
210 if (args.length < 2) {
211 logger.warn("Unown channel {} for command {}", channelUID, command);
214 String type = args[0];
215 String name = args[1];
217 if (AutelisBindingConstants.CMD_EQUIPMENT.equals(type)) {
218 String cmd = "value";
220 if (command == OnOffType.OFF) {
222 } else if (command == OnOffType.ON) {
224 } else if (command instanceof DecimalType) {
225 value = ((DecimalType) command).intValue();
226 if (!isJandy() && value >= 3) {
227 // this is an autelis dim type. not sure what 2 does
231 logger.error("command type {} is not supported", command);
234 String response = getUrl(baseURL + "/set.cgi?name=" + name + "&" + cmd + "=" + value,
236 logger.debug("equipment set {} {} {} : result {}", name, cmd, value, response);
237 } else if (AutelisBindingConstants.CMD_TEMP.equals(type)) {
239 if (command == IncreaseDecreaseType.INCREASE) {
241 } else if (command == IncreaseDecreaseType.DECREASE) {
243 } else if (command == OnOffType.OFF) {
245 } else if (command == OnOffType.ON) {
248 value = command.toString();
252 // name ending in sp are setpoints, ht are heater?
253 if (name.endsWith("sp")) {
255 } else if (name.endsWith("ht")) {
258 logger.error("Unknown temp type {}", name);
261 String response = getUrl(baseURL + "/set.cgi?wait=1&name=" + name + "&" + cmd + "=" + value,
263 logger.debug("temp set name:{} cmd:{} value:{} : result {}", name, cmd, value, response);
264 } else if (AutelisBindingConstants.CMD_CHEM.equals(type)) {
265 String response = getUrl(baseURL + "/set.cgi?name=" + name + "&chem=" + command.toString(),
267 logger.debug("chlrp {} {}: result {}", name, command, response);
268 } else if (AutelisBindingConstants.CMD_PUMPS.equals(type)) {
269 String response = getUrl(baseURL + "/set.cgi?name=" + name + "&speed=" + command.toString(),
271 logger.debug("pumps {} {}: result {}", name, command, response);
273 logger.error("Unsupported type {}", type);
277 // reset the schedule for our next poll which at that time will reflect if our command was successful or
279 initPolling(COMMAND_UPDATE_TIME_SECONDS);
280 } catch (InterruptedException e) {
281 Thread.currentThread().interrupt();
286 * Configures this thing
288 private void configure() {
291 AutelisConfiguration configuration = getConfig().as(AutelisConfiguration.class);
292 Integer refreshOrNull = configuration.refresh;
293 Integer portOrNull = configuration.port;
294 String host = configuration.host;
295 String username = configuration.user;
296 String password = configuration.password;
298 if (username == null || username.isBlank()) {
299 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "username must not be empty");
303 if (password == null || password.isBlank()) {
304 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "password must not be empty");
308 if (host == null || host.isBlank()) {
309 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "hostname must not be empty");
313 refresh = DEFAULT_REFRESH_SECONDS;
314 if (refreshOrNull != null) {
315 refresh = refreshOrNull.intValue();
319 if (portOrNull != null) {
320 port = portOrNull.intValue();
323 baseURL = "http://" + host + ":" + port;
324 basicAuthentication = "Basic "
325 + Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.ISO_8859_1));
326 logger.debug("Autelius binding configured with base url {} and refresh period of {}", baseURL, refresh);
332 * Starts/Restarts polling with an initial delay. This allows changes in the poll cycle for when commands are sent
333 * and we need to poll sooner then the next refresh cycle.
335 private synchronized void initPolling(int initalDelay) {
337 pollFuture = scheduler.scheduleWithFixedDelay(() -> {
339 pollAutelisController();
340 } catch (Exception e) {
341 logger.debug("Exception during poll", e);
343 }, initalDelay, DEFAULT_REFRESH_SECONDS, TimeUnit.SECONDS);
347 * Stops/clears this thing's polling future
349 private void clearPolling() {
350 if (pollFuture != null && !pollFuture.isCancelled()) {
351 logger.trace("Canceling future");
352 pollFuture.cancel(false);
357 * Poll the Autelis controller for updates. This will retrieve various xml documents and update channel states from
360 private void pollAutelisController() throws InterruptedException {
361 logger.trace("Connecting to {}", baseURL);
363 // clear our cached stated IF it is time.
366 // we will reconstruct the document with all the responses combined for XPATH
367 StringBuilder sb = new StringBuilder("<response>");
369 // pull down the three xml documents
370 String[] statuses = { "status", "chem", "pumps" };
372 for (String status : statuses) {
373 String response = getUrl(baseURL + "/" + status + ".xml", TIMEOUT_SECONDS);
374 logger.trace("{}/{}.xml \n {}", baseURL, status, response);
375 if (response == null) {
376 // all models and versions have the status.xml endpoint
377 if (status.equals("status")) {
378 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
381 // not all models have the other endpoints, so we ignore errors
385 // get the xml data between the response tags and append to our main
387 Matcher m = responsePattern.matcher(response);
389 sb.append(m.group(1));
392 // finish our "new" XML Document
393 sb.append("</response>");
395 if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
396 updateStatus(ThingStatus.ONLINE);
400 * This xmlDoc will now contain the three XML documents we retrieved
401 * wrapped in response tags for easier querying in XPath.
403 HashMap<String, String> pumps = new HashMap<>();
404 String xmlDoc = sb.toString();
405 for (Channel channel : getThing().getChannels()) {
406 String key = channel.getUID().getId().replaceFirst("-", "/");
407 XPathFactory xpathFactory = XPathFactory.newInstance();
408 XPath xpath = xpathFactory.newXPath();
410 InputSource is = new InputSource(new StringReader(xmlDoc));
414 * Work around for Pentair pumps. Rather then have child XML elements, the response rather uses commas
415 * on the pump response to separate the different values like so:
417 * watts,rpm,gpm,filter,error
419 * Also, some pools will only report the first 3 out of the 5 values.
422 Matcher matcher = PUMPS_PATTERN.matcher(key);
423 if (matcher.matches()) {
424 if (!pumps.containsKey(key)) {
425 String pumpValue = xpath.evaluate("response/" + matcher.group(1), is);
426 String[] values = pumpValue.split(",");
427 for (int i = 0; i < PUMP_TYPES.length; i++) {
429 // this will be something like pump/pump1-rpm
430 String newKey = matcher.group(1) + '-' + PUMP_TYPES[i];
432 // some Pentair models only have the first 3 values
433 if (i < values.length) {
434 pumps.put(newKey, values[i]);
436 pumps.put(newKey, "");
440 value = pumps.get(key);
442 value = xpath.evaluate("response/" + key, is);
444 // Convert pentair salt levels to PPM.
445 if ("chlor/salt".equals(key)) {
447 value = String.valueOf(Integer.parseInt(value) * 50);
448 } catch (NumberFormatException ignored) {
449 logger.debug("Failed to parse pentair salt level as integer");
454 if (value == null || value.isEmpty()) {
458 State state = toState(channel.getAcceptedItemType(), value);
459 State oldState = stateMap.put(channel.getUID().getAsString(), state);
460 if (!state.equals(oldState)) {
461 logger.trace("updating channel {} with state {} (old state {})", channel.getUID(), state, oldState);
462 updateState(channel.getUID(), state);
464 } catch (XPathExpressionException e) {
465 logger.error("could not parse xml", e);
471 * Simple logic to perform an authenticated GET request
477 private synchronized String getUrl(String url, int timeout) throws InterruptedException {
478 // throttle commands for a very short time to avoid 'loosing' them
479 long now = System.currentTimeMillis();
480 long nextReq = lastRequestTime + THROTTLE_TIME_MILLISECONDS;
482 logger.trace("Throttling request for {} mills", nextReq - now);
483 Thread.sleep(nextReq - now);
485 String getURL = url + (url.contains("?") ? "&" : "?") + "timestamp=" + System.currentTimeMillis();
486 logger.trace("Getting URL {} ", getURL);
487 Request request = client.newRequest(getURL).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS);
488 request.header(HttpHeader.AUTHORIZATION, basicAuthentication);
490 ContentResponse response = request.send();
491 int statusCode = response.getStatus();
492 if (statusCode != HttpStatus.OK_200) {
493 logger.trace("Method failed: {}", response.getStatus() + " " + response.getReason());
496 lastRequestTime = System.currentTimeMillis();
497 return response.getContentAsString();
498 } catch (ExecutionException | TimeoutException e) {
499 logger.debug("Could not make http connection", e);
505 * Converts a {@link String} value to a {@link State} for a given
506 * {@link String} accepted type
510 * @return {@link State}
512 private State toState(String type, String value) throws NumberFormatException {
513 if ("Number".equals(type)) {
514 return new DecimalType(value);
515 } else if ("Switch".equals(type)) {
516 return Integer.parseInt(value) > 0 ? OnOffType.ON : OnOffType.OFF;
518 return StringType.valueOf(value);
523 * Clears our state if it is time
525 private void clearState(boolean force) {
526 if (force || System.currentTimeMillis() >= clearTime) {
528 clearTime = System.currentTimeMillis() + (NORMAL_CLEARTIME_SECONDS * 1000);
532 private void startHttpClient(HttpClient client) {
533 if (!client.isStarted()) {
536 } catch (Exception e) {
537 logger.error("Could not stop HttpClient", e);
542 private void stopHttpClient(HttpClient client) {
543 if (client != null) {
544 client.getAuthenticationStore().clearAuthentications();
545 client.getAuthenticationStore().clearAuthenticationResults();
546 if (client.isStarted()) {
549 } catch (Exception e) {
550 logger.error("Could not stop HttpClient", e);
556 private boolean isJandy() {
557 return AutelisBindingConstants.JANDY_THING_TYPE_UID.equals(getThing().getThingTypeUID());