]> git.basschouten.com Git - openhab-addons.git/blob
629395f6ba772b87182e4ea61a44084ddf4776ad
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.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;
27
28 import javax.xml.xpath.XPath;
29 import javax.xml.xpath.XPathExpressionException;
30 import javax.xml.xpath.XPathFactory;
31
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;
54
55 /**
56  *
57  * Autelis Pool Control Binding
58  *
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
61  * these controllers.
62  *
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
66  *      API
67  *
68  *      The {@link AutelisHandler} is responsible for handling commands, which
69  *      are sent to one of the channels.
70  *
71  * @author Dan Cunningham - Initial contribution
72  * @author Svilen Valkanov - Replaced Apache HttpClient with Jetty
73  */
74 public class AutelisHandler extends BaseThingHandler {
75
76     private final Logger logger = LoggerFactory.getLogger(AutelisHandler.class);
77
78     /**
79      * Default timeout for http connections to an Autelis controller
80      */
81     static final int TIMEOUT_SECONDS = 5;
82
83     /**
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
89      * sent.
90      */
91     private Map<String, State> stateMap = Collections.synchronizedMap(new HashMap<>());
92
93     /**
94      * Clear our state every hour
95      */
96     private static final int NORMAL_CLEARTIME_SECONDS = 60 * 60;
97
98     /**
99      * Default poll rate rate, this is derived from the Autelis web UI
100      */
101     private static final int DEFAULT_REFRESH_SECONDS = 3;
102
103     /**
104      * How long should we wait to poll after we send an update, derived from trial and error
105      */
106     private static final int COMMAND_UPDATE_TIME_SECONDS = 6;
107
108     /**
109      * The autelis unit will 'loose' commands if sent to fast
110      */
111     private static final int THROTTLE_TIME_MILLISECONDS = 500;
112
113     /**
114      * Autelis web port
115      */
116     private static final int WEB_PORT = 80;
117
118     /**
119      * Pentair values for pump response
120      */
121     private static final String[] PUMP_TYPES = { "watts", "rpm", "gpm", "filer", "error" };
122
123     /**
124      * Matcher for pump channel names for Pentair
125      */
126     private static final Pattern PUMPS_PATTERN = Pattern.compile("(pumps/pump\\d?)-(watts|rpm|gpm|filter|error)");
127
128     /**
129      * Holds the next clear time in millis
130      */
131     private long clearTime;
132
133     /**
134      * Constructed URL consisting of host and port
135      */
136     private String baseURL;
137
138     /**
139      * Our poll rate
140      */
141     private int refresh;
142
143     /**
144      * The http client used for polling requests
145      */
146     private HttpClient client = new HttpClient();
147
148     /**
149      * last time we finished a request
150      */
151     private long lastRequestTime = 0;
152
153     /**
154      * Authentication for login
155      */
156     private String basicAuthentication;
157
158     /**
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
162      */
163     private Pattern responsePattern = Pattern.compile("<response>(.+?)</response>", Pattern.DOTALL);
164
165     /**
166      * Future to poll for updated
167      */
168     private ScheduledFuture<?> pollFuture;
169
170     public AutelisHandler(Thing thing) {
171         super(thing);
172     }
173
174     @Override
175     public void initialize() {
176         startHttpClient(client);
177         configure();
178     }
179
180     @Override
181     public void dispose() {
182         logger.debug("Handler disposed.");
183         clearPolling();
184         stopHttpClient(client);
185     }
186
187     @Override
188     public void channelLinked(ChannelUID channelUID) {
189         // clear our cached values so the new channel gets updated
190         clearState(true);
191     }
192
193     @Override
194     public void handleCommand(ChannelUID channelUID, Command command) {
195         try {
196             logger.debug("handleCommand channel: {} command: {}", channelUID.getId(), command);
197             if (AutelisBindingConstants.CMD_LIGHTS.equals(channelUID.getId())) {
198                 /*
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
203                  */
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);
208             } else {
209                 String[] args = channelUID.getId().split("-");
210                 if (args.length < 2) {
211                     logger.warn("Unown channel {} for command {}", channelUID, command);
212                     return;
213                 }
214                 String type = args[0];
215                 String name = args[1];
216
217                 if (AutelisBindingConstants.CMD_EQUIPMENT.equals(type)) {
218                     String cmd = "value";
219                     int value;
220                     if (command == OnOffType.OFF) {
221                         value = 0;
222                     } else if (command == OnOffType.ON) {
223                         value = 1;
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
228                             cmd = "dim";
229                         }
230                     } else {
231                         logger.error("command type {} is not supported", command);
232                         return;
233                     }
234                     String response = getUrl(baseURL + "/set.cgi?name=" + name + "&" + cmd + "=" + value,
235                             TIMEOUT_SECONDS);
236                     logger.debug("equipment set {} {} {} : result {}", name, cmd, value, response);
237                 } else if (AutelisBindingConstants.CMD_TEMP.equals(type)) {
238                     String value;
239                     if (command == IncreaseDecreaseType.INCREASE) {
240                         value = "up";
241                     } else if (command == IncreaseDecreaseType.DECREASE) {
242                         value = "down";
243                     } else if (command == OnOffType.OFF) {
244                         value = "0";
245                     } else if (command == OnOffType.ON) {
246                         value = "1";
247                     } else {
248                         value = command.toString();
249                     }
250
251                     String cmd;
252                     // name ending in sp are setpoints, ht are heater?
253                     if (name.endsWith("sp")) {
254                         cmd = "temp";
255                     } else if (name.endsWith("ht")) {
256                         cmd = "hval";
257                     } else {
258                         logger.error("Unknown temp type {}", name);
259                         return;
260                     }
261                     String response = getUrl(baseURL + "/set.cgi?wait=1&name=" + name + "&" + cmd + "=" + value,
262                             TIMEOUT_SECONDS);
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(),
266                             TIMEOUT_SECONDS);
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(),
270                             TIMEOUT_SECONDS);
271                     logger.debug("pumps {} {}: result {}", name, command, response);
272                 } else {
273                     logger.error("Unsupported type {}", type);
274                 }
275             }
276             clearState(true);
277             // reset the schedule for our next poll which at that time will reflect if our command was successful or
278             // not.
279             initPolling(COMMAND_UPDATE_TIME_SECONDS);
280         } catch (InterruptedException e) {
281             Thread.currentThread().interrupt();
282         }
283     }
284
285     /**
286      * Configures this thing
287      */
288     private void configure() {
289         clearPolling();
290
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;
297
298         if (username == null || username.isBlank()) {
299             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "username must not be empty");
300             return;
301         }
302
303         if (password == null || password.isBlank()) {
304             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "password must not be empty");
305             return;
306         }
307
308         if (host == null || host.isBlank()) {
309             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "hostname must not be empty");
310             return;
311         }
312
313         refresh = DEFAULT_REFRESH_SECONDS;
314         if (refreshOrNull != null) {
315             refresh = refreshOrNull.intValue();
316         }
317
318         int port = WEB_PORT;
319         if (portOrNull != null) {
320             port = portOrNull.intValue();
321         }
322
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);
327
328         initPolling(0);
329     }
330
331     /**
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.
334      */
335     private synchronized void initPolling(int initalDelay) {
336         clearPolling();
337         pollFuture = scheduler.scheduleWithFixedDelay(() -> {
338             try {
339                 pollAutelisController();
340             } catch (Exception e) {
341                 logger.debug("Exception during poll", e);
342             }
343         }, initalDelay, DEFAULT_REFRESH_SECONDS, TimeUnit.SECONDS);
344     }
345
346     /**
347      * Stops/clears this thing's polling future
348      */
349     private void clearPolling() {
350         if (pollFuture != null && !pollFuture.isCancelled()) {
351             logger.trace("Canceling future");
352             pollFuture.cancel(false);
353         }
354     }
355
356     /**
357      * Poll the Autelis controller for updates. This will retrieve various xml documents and update channel states from
358      * its contents.
359      */
360     private void pollAutelisController() throws InterruptedException {
361         logger.trace("Connecting to {}", baseURL);
362
363         // clear our cached stated IF it is time.
364         clearState(false);
365
366         // we will reconstruct the document with all the responses combined for XPATH
367         StringBuilder sb = new StringBuilder("<response>");
368
369         // pull down the three xml documents
370         String[] statuses = { "status", "chem", "pumps" };
371
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);
379                     return;
380                 } else {
381                     // not all models have the other endpoints, so we ignore errors
382                     continue;
383                 }
384             }
385             // get the xml data between the response tags and append to our main
386             // doc
387             Matcher m = responsePattern.matcher(response);
388             if (m.find()) {
389                 sb.append(m.group(1));
390             }
391         }
392         // finish our "new" XML Document
393         sb.append("</response>");
394
395         if (!ThingStatus.ONLINE.equals(getThing().getStatus())) {
396             updateStatus(ThingStatus.ONLINE);
397         }
398
399         /*
400          * This xmlDoc will now contain the three XML documents we retrieved
401          * wrapped in response tags for easier querying in XPath.
402          */
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();
409             try {
410                 InputSource is = new InputSource(new StringReader(xmlDoc));
411                 String value = null;
412
413                 /**
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:
416                  *
417                  * watts,rpm,gpm,filter,error
418                  *
419                  * Also, some pools will only report the first 3 out of the 5 values.
420                  */
421
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++) {
428
429                             // this will be something like pump/pump1-rpm
430                             String newKey = matcher.group(1) + '-' + PUMP_TYPES[i];
431
432                             // some Pentair models only have the first 3 values
433                             if (i < values.length) {
434                                 pumps.put(newKey, values[i]);
435                             } else {
436                                 pumps.put(newKey, "");
437                             }
438                         }
439                     }
440                     value = pumps.get(key);
441                 } else {
442                     value = xpath.evaluate("response/" + key, is);
443
444                     // Convert pentair salt levels to PPM.
445                     if ("chlor/salt".equals(key)) {
446                         try {
447                             value = String.valueOf(Integer.parseInt(value) * 50);
448                         } catch (NumberFormatException ignored) {
449                             logger.debug("Failed to parse pentair salt level as integer");
450                         }
451                     }
452                 }
453
454                 if (value == null || value.isEmpty()) {
455                     continue;
456                 }
457
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);
463                 }
464             } catch (XPathExpressionException e) {
465                 logger.error("could not parse xml", e);
466             }
467         }
468     }
469
470     /**
471      * Simple logic to perform an authenticated GET request
472      *
473      * @param url
474      * @param timeout
475      * @return
476      */
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;
481         if (nextReq > now) {
482             logger.trace("Throttling request for {} mills", nextReq - now);
483             Thread.sleep(nextReq - now);
484         }
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);
489         try {
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());
494                 return null;
495             }
496             lastRequestTime = System.currentTimeMillis();
497             return response.getContentAsString();
498         } catch (ExecutionException | TimeoutException e) {
499             logger.debug("Could not make http connection", e);
500         }
501         return null;
502     }
503
504     /**
505      * Converts a {@link String} value to a {@link State} for a given
506      * {@link String} accepted type
507      *
508      * @param itemType
509      * @param value
510      * @return {@link State}
511      */
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;
517         } else {
518             return StringType.valueOf(value);
519         }
520     }
521
522     /**
523      * Clears our state if it is time
524      */
525     private void clearState(boolean force) {
526         if (force || System.currentTimeMillis() >= clearTime) {
527             stateMap.clear();
528             clearTime = System.currentTimeMillis() + (NORMAL_CLEARTIME_SECONDS * 1000);
529         }
530     }
531
532     private void startHttpClient(HttpClient client) {
533         if (!client.isStarted()) {
534             try {
535                 client.start();
536             } catch (Exception e) {
537                 logger.error("Could not stop HttpClient", e);
538             }
539         }
540     }
541
542     private void stopHttpClient(HttpClient client) {
543         if (client != null) {
544             client.getAuthenticationStore().clearAuthentications();
545             client.getAuthenticationStore().clearAuthenticationResults();
546             if (client.isStarted()) {
547                 try {
548                     client.stop();
549                 } catch (Exception e) {
550                     logger.error("Could not stop HttpClient", e);
551                 }
552             }
553         }
554     }
555
556     private boolean isJandy() {
557         return AutelisBindingConstants.JANDY_THING_TYPE_UID.equals(getThing().getThingTypeUID());
558     }
559 }