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