]> git.basschouten.com Git - openhab-addons.git/blob
3669f87a56ed1e594f3039d3cdb20c9f7b9cc728
[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.nio.charset.StandardCharsets;
17 import java.util.Base64;
18 import java.util.Collections;
19 import java.util.HashMap;
20 import java.util.Map;
21 import java.util.concurrent.ScheduledFuture;
22 import java.util.concurrent.TimeUnit;
23 import java.util.regex.Matcher;
24 import java.util.regex.Pattern;
25
26 import javax.xml.xpath.XPath;
27 import javax.xml.xpath.XPathExpressionException;
28 import javax.xml.xpath.XPathFactory;
29
30 import org.eclipse.jetty.client.HttpClient;
31 import org.eclipse.jetty.client.api.ContentResponse;
32 import org.eclipse.jetty.client.api.Request;
33 import org.eclipse.jetty.http.HttpHeader;
34 import org.eclipse.jetty.http.HttpStatus;
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 "
317                 + Base64.getEncoder().encodeToString((username + ":" + password).getBytes(StandardCharsets.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 (value == null || value.isEmpty()) {
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 }