]> git.basschouten.com Git - openhab-addons.git/blob
1136df94e2385053e538f7acbcc93043c6e4cf44
[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.autelis.internal.handler;
14
15 import java.io.StringReader;
16 import java.util.Collections;
17 import java.util.HashMap;
18 import java.util.Map;
19 import java.util.concurrent.ScheduledFuture;
20 import java.util.concurrent.TimeUnit;
21 import java.util.regex.Matcher;
22 import java.util.regex.Pattern;
23
24 import javax.xml.xpath.XPath;
25 import javax.xml.xpath.XPathExpressionException;
26 import javax.xml.xpath.XPathFactory;
27
28 import org.apache.commons.lang.StringUtils;
29 import org.eclipse.jetty.client.HttpClient;
30 import org.eclipse.jetty.client.api.ContentResponse;
31 import org.eclipse.jetty.client.api.Request;
32 import org.eclipse.jetty.http.HttpHeader;
33 import org.eclipse.jetty.http.HttpStatus;
34 import org.eclipse.jetty.util.B64Code;
35 import org.eclipse.jetty.util.StringUtil;
36 import org.openhab.binding.autelis.internal.AutelisBindingConstants;
37 import org.openhab.binding.autelis.internal.config.AutelisConfiguration;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.library.types.IncreaseDecreaseType;
40 import org.openhab.core.library.types.OnOffType;
41 import org.openhab.core.library.types.StringType;
42 import org.openhab.core.thing.Channel;
43 import org.openhab.core.thing.ChannelUID;
44 import org.openhab.core.thing.Thing;
45 import org.openhab.core.thing.ThingStatus;
46 import org.openhab.core.thing.ThingStatusDetail;
47 import org.openhab.core.thing.binding.BaseThingHandler;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.State;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52 import org.xml.sax.InputSource;
53
54 /**
55  *
56  * Autelis Pool Control Binding
57  *
58  * Autelis controllers allow remote access to many common pool systems. This
59  * binding allows openHAB to both monitor and control a pool system through
60  * these controllers.
61  *
62  * @see <a href="http://Autelis.com">http://autelis.com</a>
63  * @see <a href="http://www.autelis.com/wiki/index.php?title=Pool_Control_HTTP_Command_Reference"</a> for Jandy API
64  * @see <a href="http://www.autelis.com/wiki/index.php?title=Pool_Control_(PI)_HTTP_Command_Reference"</a> for Pentair
65  *      API
66  *
67  *      The {@link AutelisHandler} is responsible for handling commands, which
68  *      are sent to one of the channels.
69  *
70  * @author Dan Cunningham - Initial contribution
71  * @author Svilen Valkanov - Replaced Apache HttpClient with Jetty
72  */
73 public class AutelisHandler extends BaseThingHandler {
74
75     private final Logger logger = LoggerFactory.getLogger(AutelisHandler.class);
76
77     /**
78      * Default timeout for http connections to a Autelis controller
79      */
80     static final int TIMEOUT_SECONDS = 5;
81
82     /**
83      * Autelis controllers will not update their XML immediately after we change
84      * a value. To compensate we cache previous values for a {@link Channel}
85      * using the item name as a key. After a polling run has been executed we
86      * only update an channel if the value is different then what's in the
87      * cache. This cache is cleared after a fixed time period when commands are
88      * sent.
89      */
90     private Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
91
92     /**
93      * Clear our state every hour
94      */
95     private static final int NORMAL_CLEARTIME_SECONDS = 60 * 60;
96
97     /**
98      * Default poll rate rate, this is derived from the Autelis web UI
99      */
100     private static final int DEFAULT_REFRESH_SECONDS = 3;
101
102     /**
103      * How long should we wait to poll after we send an update, derived from trial and error
104      */
105     private static final int COMMAND_UPDATE_TIME_SECONDS = 6;
106
107     /**
108      * The autelis unit will 'loose' commands if sent to fast
109      */
110     private static final int THROTTLE_TIME_MILLISECONDS = 500;
111
112     /**
113      * Autelis web port
114      */
115     private static final int WEB_PORT = 80;
116
117     /**
118      * Pentair values for pump response
119      */
120     private static final String[] PUMP_TYPES = { "watts", "rpm", "gpm", "filer", "error" };
121
122     /**
123      * Matcher for pump channel names for Pentair
124      */
125     private static final Pattern PUMPS_PATTERN = Pattern.compile("(pumps/pump\\d?)-(watts|rpm|gpm|filter|error)");
126
127     /**
128      * Holds the next clear time in millis
129      */
130     private long clearTime;
131
132     /**
133      * Constructed URL consisting of host and port
134      */
135     private String baseURL;
136
137     /**
138      * Our poll rate
139      */
140     private int refresh;
141
142     /**
143      * The http client used for polling requests
144      */
145     private HttpClient client = new HttpClient();
146
147     /**
148      * last time we finished a request
149      */
150     private long lastRequestTime = 0;
151
152     /**
153      * Authentication for login
154      */
155     private String basicAuthentication;
156
157     /**
158      * Regex expression to match XML responses from the Autelis, this is used to
159      * combine similar XML docs into a single document, {@link XPath} is still
160      * used for XML querying
161      */
162     private Pattern responsePattern = Pattern.compile("<response>(.+?)</response>", Pattern.DOTALL);
163
164     /**
165      * Future to poll for updated
166      */
167     private ScheduledFuture<?> pollFuture;
168
169     public AutelisHandler(Thing thing) {
170         super(thing);
171     }
172
173     @Override
174     public void initialize() {
175         startHttpClient(client);
176         configure();
177     }
178
179     @Override
180     public void dispose() {
181         logger.debug("Handler disposed.");
182         clearPolling();
183         stopHttpClient(client);
184     }
185
186     @Override
187     public void channelLinked(ChannelUID channelUID) {
188         // clear our cached values so the new channel gets updated
189         clearState(true);
190     }
191
192     @Override
193     public void handleCommand(ChannelUID channelUID, Command command) {
194         logger.debug("handleCommand channel: {} command: {}", channelUID.getId(), command);
195         if (AutelisBindingConstants.CMD_LIGHTS.equals(channelUID.getId())) {
196             /*
197              * lighting command possible values, but we will let anything
198              * through. alloff, allon, csync, cset, cswim, party, romance,
199              * caribbean, american, sunset, royalty, blue, green, red, white,
200              * magenta, hold, recall
201              */
202             getUrl(baseURL + "/lights.cgi?val=" + command.toString(), TIMEOUT_SECONDS);
203         } else if (AutelisBindingConstants.CMD_REBOOT.equals(channelUID.getId()) && command == OnOffType.ON) {
204             getUrl(baseURL + "/userreboot.cgi?do=true" + command.toString(), TIMEOUT_SECONDS);
205             updateState(channelUID, OnOffType.OFF);
206         } else {
207             String[] args = channelUID.getId().split("-");
208             if (args.length < 2) {
209                 logger.warn("Unown channel {} for command {}", channelUID, command);
210                 return;
211             }
212             String type = args[0];
213             String name = args[1];
214
215             if (AutelisBindingConstants.CMD_EQUIPMENT.equals(type)) {
216                 String cmd = "value";
217                 int value;
218                 if (command == OnOffType.OFF) {
219                     value = 0;
220                 } else if (command == OnOffType.ON) {
221                     value = 1;
222                 } else if (command instanceof DecimalType) {
223                     value = ((DecimalType) command).intValue();
224                     if (!isJandy() && value >= 3) {
225                         // this is a autelis dim type. not sure what 2 does
226                         cmd = "dim";
227                     }
228                 } else {
229                     logger.error("command type {} is not supported", command);
230                     return;
231                 }
232                 String response = getUrl(baseURL + "/set.cgi?name=" + name + "&" + cmd + "=" + value, TIMEOUT_SECONDS);
233                 logger.debug("equipment set {} {} {} : result {}", name, cmd, value, response);
234             } else if (AutelisBindingConstants.CMD_TEMP.equals(type)) {
235                 String value;
236                 if (command == IncreaseDecreaseType.INCREASE) {
237                     value = "up";
238                 } else if (command == IncreaseDecreaseType.DECREASE) {
239                     value = "down";
240                 } else if (command == OnOffType.OFF) {
241                     value = "0";
242                 } else if (command == OnOffType.ON) {
243                     value = "1";
244                 } else {
245                     value = command.toString();
246                 }
247
248                 String cmd;
249                 // name ending in sp are setpoints, ht are heater?
250                 if (name.endsWith("sp")) {
251                     cmd = "temp";
252                 } else if (name.endsWith("ht")) {
253                     cmd = "hval";
254                 } else {
255                     logger.error("Unknown temp type {}", name);
256                     return;
257                 }
258                 String response = getUrl(baseURL + "/set.cgi?wait=1&name=" + name + "&" + cmd + "=" + value,
259                         TIMEOUT_SECONDS);
260                 logger.debug("temp set name:{} cmd:{} value:{} : result {}", name, cmd, value, response);
261             } else if (AutelisBindingConstants.CMD_CHEM.equals(type)) {
262                 String response = getUrl(baseURL + "/set.cgi?name=" + name + "&chem=" + command.toString(),
263                         TIMEOUT_SECONDS);
264                 logger.debug("chlrp {} {}: result {}", name, command, response);
265             } else if (AutelisBindingConstants.CMD_PUMPS.equals(type)) {
266                 String response = getUrl(baseURL + "/set.cgi?name=" + name + "&speed=" + command.toString(),
267                         TIMEOUT_SECONDS);
268                 logger.debug("pumps {} {}: result {}", name, command, response);
269             } else {
270                 logger.error("Unsupported type {}", type);
271             }
272         }
273         clearState(true);
274         // reset the schedule for our next poll which at that time will reflect if our command was successful or not.
275         initPolling(COMMAND_UPDATE_TIME_SECONDS);
276     }
277
278     /**
279      * Configures this thing
280      */
281     private void configure() {
282         clearPolling();
283
284         AutelisConfiguration configuration = getConfig().as(AutelisConfiguration.class);
285         Integer refreshOrNull = configuration.refresh;
286         Integer portOrNull = configuration.port;
287         String host = configuration.host;
288         String username = configuration.user;
289         String password = configuration.password;
290
291         if (StringUtils.isBlank(username)) {
292             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "username must not be empty");
293             return;
294         }
295
296         if (StringUtils.isBlank(password)) {
297             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "password must not be empty");
298             return;
299         }
300
301         if (StringUtils.isBlank(host)) {
302             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "hostname must not be empty");
303             return;
304         }
305
306         refresh = DEFAULT_REFRESH_SECONDS;
307         if (refreshOrNull != null) {
308             refresh = refreshOrNull.intValue();
309         }
310
311         int port = WEB_PORT;
312         if (portOrNull != null) {
313             port = portOrNull.intValue();
314         }
315
316         baseURL = "http://" + host + ":" + port;
317         basicAuthentication = "Basic " + B64Code.encode(username + ":" + password, StringUtil.__ISO_8859_1);
318         logger.debug("Autelius binding configured with base url {} and refresh period of {}", baseURL, refresh);
319
320         initPolling(0);
321     }
322
323     /**
324      * Starts/Restarts polling with an initial delay. This allows changes in the poll cycle for when commands are sent
325      * and we need to poll sooner then the next refresh cycle.
326      */
327     private synchronized void initPolling(int initalDelay) {
328         clearPolling();
329         pollFuture = scheduler.scheduleWithFixedDelay(() -> {
330             try {
331                 pollAutelisController();
332             } catch (Exception e) {
333                 logger.debug("Exception during poll", e);
334             }
335         }, initalDelay, DEFAULT_REFRESH_SECONDS, TimeUnit.SECONDS);
336     }
337
338     /**
339      * Stops/clears this thing's polling future
340      */
341     private void clearPolling() {
342         if (pollFuture != null && !pollFuture.isCancelled()) {
343             logger.trace("Canceling future");
344             pollFuture.cancel(false);
345         }
346     }
347
348     /**
349      * Poll the Autelis controller for updates. This will retrieve various xml documents and update channel states from
350      * its contents.
351      */
352     private void pollAutelisController() {
353         logger.trace("Connecting to {}", baseURL);
354
355         // clear our cached stated IF it is time.
356         clearState(false);
357
358         // we will reconstruct the document with all the responses combined for XPATH
359         StringBuilder sb = new StringBuilder("<response>");
360
361         // pull down the three xml documents
362         String[] statuses = { "status", "chem", "pumps" };
363
364         for (String status : statuses) {
365             String response = getUrl(baseURL + "/" + status + ".xml", TIMEOUT_SECONDS);
366             logger.trace("{}/{}.xml \n {}", baseURL, status, response);
367             if (response == null) {
368                 // all models and versions have the status.xml endpoint
369                 if (status.equals("status")) {
370                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR);
371                     return;
372                 } else {
373                     // not all models have the other endpoints, so we ignore errors
374                     continue;
375                 }
376             }
377             // get the xml data between the response tags and append to our main
378             // doc
379             Matcher m = responsePattern.matcher(response);
380             if (m.find()) {
381                 sb.append(m.group(1));
382             }
383         }
384         // finish our "new" XML Document
385         sb.append("</response>");
386
387         if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
388             updateStatus(ThingStatus.ONLINE);
389         }
390
391         /*
392          * This xmlDoc will now contain the three XML documents we retrieved
393          * wrapped in response tags for easier querying in XPath.
394          */
395         HashMap<String, String> pumps = new HashMap<>();
396         String xmlDoc = sb.toString();
397         for (Channel channel : getThing().getChannels()) {
398             String key = channel.getUID().getId().replaceFirst("-", "/");
399             XPathFactory xpathFactory = XPathFactory.newInstance();
400             XPath xpath = xpathFactory.newXPath();
401             try {
402                 InputSource is = new InputSource(new StringReader(xmlDoc));
403                 String value = null;
404
405                 /**
406                  * Work around for Pentair pumps. Rather then have child XML elements, the response rather uses commas
407                  * on the pump response to separate the different values like so:
408                  *
409                  * watts,rpm,gpm,filter,error
410                  *
411                  * Also, some pools will only report the first 3 out of the 5 values.
412                  */
413
414                 Matcher matcher = PUMPS_PATTERN.matcher(key);
415                 if (matcher.matches()) {
416                     if (!pumps.containsKey(key)) {
417                         String pumpValue = xpath.evaluate("response/" + matcher.group(1), is);
418                         String[] values = pumpValue.split(",");
419                         for (int i = 0; i < PUMP_TYPES.length; i++) {
420
421                             // this will be something like pump/pump1-rpm
422                             String newKey = matcher.group(1) + '-' + PUMP_TYPES[i];
423
424                             // some Pentair models only have the first 3 values
425                             if (i < values.length) {
426                                 pumps.put(newKey, values[i]);
427                             } else {
428                                 pumps.put(newKey, "");
429                             }
430                         }
431                     }
432                     value = pumps.get(key);
433                 } else {
434                     value = xpath.evaluate("response/" + key, is);
435
436                     // Convert pentair salt levels to PPM.
437                     if ("chlor/salt".equals(key)) {
438                         try {
439                             value = String.valueOf(Integer.parseInt(value) * 50);
440                         } catch (NumberFormatException ignored) {
441                             logger.debug("Failed to parse pentair salt level as integer");
442                         }
443                     }
444                 }
445
446                 if (StringUtils.isEmpty((value))) {
447                     continue;
448                 }
449
450                 State state = toState(channel.getAcceptedItemType(), value);
451                 State oldState = stateMap.put(channel.getUID().getAsString(), state);
452                 if (!state.equals(oldState)) {
453                     logger.trace("updating channel {} with state {} (old state {})", channel.getUID(), state, oldState);
454                     updateState(channel.getUID(), state);
455                 }
456             } catch (XPathExpressionException e) {
457                 logger.error("could not parse xml", e);
458             }
459         }
460     }
461
462     /**
463      * Simple logic to perform a authenticated GET request
464      *
465      * @param url
466      * @param timeout
467      * @return
468      */
469     private synchronized String getUrl(String url, int timeout) {
470         // throttle commands for a very short time to avoid 'loosing' them
471         long now = System.currentTimeMillis();
472         long nextReq = lastRequestTime + THROTTLE_TIME_MILLISECONDS;
473         if (nextReq > now) {
474             try {
475                 logger.trace("Throttling request for {} mills", nextReq - now);
476                 Thread.sleep(nextReq - now);
477             } catch (InterruptedException ignored) {
478             }
479         }
480         String getURL = url + (url.contains("?") ? "&" : "?") + "timestamp=" + System.currentTimeMillis();
481         logger.trace("Getting URL {} ", getURL);
482         Request request = client.newRequest(getURL).timeout(TIMEOUT_SECONDS, TimeUnit.SECONDS);
483         request.header(HttpHeader.AUTHORIZATION, basicAuthentication);
484         try {
485             ContentResponse response = request.send();
486             int statusCode = response.getStatus();
487             if (statusCode != HttpStatus.OK_200) {
488                 logger.trace("Method failed: {}", response.getStatus() + " " + response.getReason());
489                 return null;
490             }
491             lastRequestTime = System.currentTimeMillis();
492             return response.getContentAsString();
493         } catch (Exception e) {
494             logger.debug("Could not make http connection", e);
495         }
496         return null;
497     }
498
499     /**
500      * Converts a {@link String} value to a {@link State} for a given
501      * {@link String} accepted type
502      *
503      * @param itemType
504      * @param value
505      * @return {@link State}
506      */
507     private State toState(String type, String value) throws NumberFormatException {
508         if ("Number".equals(type)) {
509             return new DecimalType(value);
510         } else if ("Switch".equals(type)) {
511             return Integer.parseInt(value) > 0 ? OnOffType.ON : OnOffType.OFF;
512         } else {
513             return StringType.valueOf(value);
514         }
515     }
516
517     /**
518      * Clears our state if it is time
519      */
520     private void clearState(boolean force) {
521         if (force || System.currentTimeMillis() >= clearTime) {
522             stateMap.clear();
523             clearTime = System.currentTimeMillis() + (NORMAL_CLEARTIME_SECONDS * 1000);
524         }
525     }
526
527     private void startHttpClient(HttpClient client) {
528         if (!client.isStarted()) {
529             try {
530                 client.start();
531             } catch (Exception e) {
532                 logger.error("Could not stop HttpClient", e);
533             }
534         }
535     }
536
537     private void stopHttpClient(HttpClient client) {
538         if (client != null) {
539             client.getAuthenticationStore().clearAuthentications();
540             client.getAuthenticationStore().clearAuthenticationResults();
541             if (client.isStarted()) {
542                 try {
543                     client.stop();
544                 } catch (Exception e) {
545                     logger.error("Could not stop HttpClient", e);
546                 }
547             }
548         }
549     }
550
551     private boolean isJandy() {
552         return getThing().getThingTypeUID() == AutelisBindingConstants.JANDY_THING_TYPE_UID;
553     }
554 }