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