]> git.basschouten.com Git - openhab-addons.git/blob
aea1bd3607a1fca08baa7f4ad81bc292b080f050
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.exec.internal.handler;
14
15 import static org.openhab.binding.exec.internal.ExecBindingConstants.*;
16
17 import java.io.BufferedReader;
18 import java.io.IOException;
19 import java.io.InputStreamReader;
20 import java.math.BigDecimal;
21 import java.time.ZonedDateTime;
22 import java.util.Arrays;
23 import java.util.Calendar;
24 import java.util.IllegalFormatException;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.regex.Matcher;
28 import java.util.regex.Pattern;
29 import java.util.regex.PatternSyntaxException;
30
31 import org.apache.commons.lang.StringUtils;
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.exec.internal.ExecWhitelistWatchService;
35 import org.openhab.core.library.types.DateTimeType;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.OnOffType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.Thing;
41 import org.openhab.core.thing.ThingStatus;
42 import org.openhab.core.thing.binding.BaseThingHandler;
43 import org.openhab.core.transform.TransformationException;
44 import org.openhab.core.transform.TransformationHelper;
45 import org.openhab.core.transform.TransformationService;
46 import org.openhab.core.types.Command;
47 import org.openhab.core.types.RefreshType;
48 import org.osgi.framework.BundleContext;
49 import org.osgi.framework.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 /**
54  * The {@link ExecHandler} is responsible for handling commands, which are
55  * sent to one of the channels.
56  *
57  * @author Karel Goderis - Initial contribution
58  * @author Constantin Piber - Added better argument support (delimiter and pass to shell)
59  * @author Jan N. Klug - Add command whitelist check
60  */
61 @NonNullByDefault
62 public class ExecHandler extends BaseThingHandler {
63     /**
64      * Use this to separate between command and parameter, and also between parameters.
65      */
66     public static final String CMD_LINE_DELIMITER = "@@";
67
68     /**
69      * Shell executables
70      */
71     public static final String[] SHELL_WINDOWS = new String[] { "cmd" };
72     public static final String[] SHELL_NIX = new String[] { "sh", "bash", "zsh", "csh" };
73     private final ExecWhitelistWatchService execWhitelistWatchService;
74
75     private Logger logger = LoggerFactory.getLogger(ExecHandler.class);
76
77     private final BundleContext bundleContext;
78
79     // List of Configurations constants
80     public static final String INTERVAL = "interval";
81     public static final String TIME_OUT = "timeout";
82     public static final String COMMAND = "command";
83     public static final String TRANSFORM = "transform";
84     public static final String AUTORUN = "autorun";
85
86     // RegEx to extract a parse a function String <code>'(.*?)\((.*)\)'</code>
87     private static final Pattern EXTRACT_FUNCTION_PATTERN = Pattern.compile("(.*?)\\((.*)\\)");
88
89     private @Nullable ScheduledFuture<?> executionJob;
90     private @Nullable String lastInput;
91
92     private static Runtime rt = Runtime.getRuntime();
93
94     public ExecHandler(Thing thing, ExecWhitelistWatchService execWhitelistWatchService) {
95         super(thing);
96         this.bundleContext = FrameworkUtil.getBundle(ExecHandler.class).getBundleContext();
97         this.execWhitelistWatchService = execWhitelistWatchService;
98     }
99
100     @Override
101     public void handleCommand(ChannelUID channelUID, Command command) {
102         if (command instanceof RefreshType) {
103             // Placeholder for later refinement
104         } else {
105             if (channelUID.getId().equals(RUN)) {
106                 if (command instanceof OnOffType) {
107                     if (command == OnOffType.ON) {
108                         scheduler.schedule(this::execute, 0, TimeUnit.SECONDS);
109                     }
110                 }
111             } else if (channelUID.getId().equals(INPUT)) {
112                 if (command instanceof StringType) {
113                     String previousInput = lastInput;
114                     lastInput = command.toString();
115                     if (lastInput != null && !lastInput.equals(previousInput)) {
116                         if (getConfig().get(AUTORUN) != null && ((Boolean) getConfig().get(AUTORUN))) {
117                             logger.trace("Executing command '{}' after a change of the input channel to '{}'",
118                                     getConfig().get(COMMAND), lastInput);
119                             scheduler.schedule(this::execute, 0, TimeUnit.SECONDS);
120                         }
121                     }
122                 }
123             }
124         }
125     }
126
127     @Override
128     public void initialize() {
129         if (executionJob == null || executionJob.isCancelled()) {
130             if ((getConfig().get(INTERVAL)) != null && ((BigDecimal) getConfig().get(INTERVAL)).intValue() > 0) {
131                 int pollingInterval = ((BigDecimal) getConfig().get(INTERVAL)).intValue();
132                 executionJob = scheduler.scheduleWithFixedDelay(this::execute, 0, pollingInterval, TimeUnit.SECONDS);
133             }
134         }
135
136         updateStatus(ThingStatus.ONLINE);
137     }
138
139     @Override
140     public void dispose() {
141         if (executionJob != null && !executionJob.isCancelled()) {
142             executionJob.cancel(true);
143             executionJob = null;
144         }
145     }
146
147     public void execute() {
148         String commandLine = (String) getConfig().get(COMMAND);
149         if (!execWhitelistWatchService.isWhitelisted(commandLine)) {
150             logger.warn("Tried to execute '{}', but it is not contained in whitelist.", commandLine);
151             return;
152         }
153
154         int timeOut = 60000;
155         if ((getConfig().get(TIME_OUT)) != null) {
156             timeOut = ((BigDecimal) getConfig().get(TIME_OUT)).intValue() * 1000;
157         }
158
159         if (commandLine != null && !commandLine.isEmpty()) {
160             updateState(RUN, OnOffType.ON);
161
162             // For some obscure reason, when using Apache Common Exec, or using a straight implementation of
163             // Runtime.Exec(), on Mac OS X (Yosemite and El Capitan), there seems to be a lock race condition
164             // randomly appearing (on UNIXProcess) *when* one tries to gobble up the stdout and sterr output of the
165             // subprocess in separate threads. It seems to be common "wisdom" to do that in separate threads, but
166             // only when keeping everything between .exec() and .waitfor() in the same thread, this lock race
167             // condition seems to go away. This approach of not reading the outputs in separate threads *might* be a
168             // problem for external commands that generate a lot of output, but this will be dependent on the limits
169             // of the underlying operating system.
170
171             try {
172                 if (lastInput != null) {
173                     commandLine = String.format(commandLine, Calendar.getInstance().getTime(), lastInput);
174                 } else {
175                     commandLine = String.format(commandLine, Calendar.getInstance().getTime());
176                 }
177             } catch (IllegalFormatException e) {
178                 logger.warn(
179                         "An exception occurred while formatting the command line with the current time and input values : '{}'",
180                         e.getMessage());
181                 updateState(RUN, OnOffType.OFF);
182                 updateState(OUTPUT, new StringType(e.getMessage()));
183                 return;
184             }
185
186             String[] cmdArray;
187             String[] shell;
188             if (commandLine.contains(CMD_LINE_DELIMITER)) {
189                 logger.debug("Splitting by '{}'", CMD_LINE_DELIMITER);
190                 try {
191                     cmdArray = commandLine.split(CMD_LINE_DELIMITER);
192                 } catch (PatternSyntaxException e) {
193                     logger.warn("An exception occurred while splitting '{}' : '{}'", commandLine, e.getMessage());
194                     updateState(RUN, OnOffType.OFF);
195                     updateState(OUTPUT, new StringType(e.getMessage()));
196                     return;
197                 }
198             } else {
199                 // Invoke shell with 'c' option and pass string
200                 logger.debug("Passing to shell for parsing command.");
201                 switch (getOperatingSystemType()) {
202                     case WINDOWS:
203                         shell = SHELL_WINDOWS;
204                         logger.debug("OS: WINDOWS ({})", getOperatingSystemName());
205                         cmdArray = createCmdArray(shell, "/c", commandLine);
206                         break;
207                     case LINUX:
208                     case MAC:
209                     case SOLARIS:
210                         // assume sh is present, should all be POSIX-compliant
211                         shell = SHELL_NIX;
212                         logger.debug("OS: *NIX ({})", getOperatingSystemName());
213                         cmdArray = createCmdArray(shell, "-c", commandLine);
214                         break;
215                     default:
216                         logger.debug("OS: Unknown ({})", getOperatingSystemName());
217                         logger.warn("OS {} not supported, please manually split commands!", getOperatingSystemName());
218                         updateState(RUN, OnOffType.OFF);
219                         updateState(OUTPUT, new StringType("OS not supported, please manually split commands!"));
220                         return;
221                 }
222             }
223
224             if (cmdArray.length == 0) {
225                 logger.trace("Empty command received, not executing");
226                 return;
227             }
228
229             logger.trace("The command to be executed will be '{}'", Arrays.asList(cmdArray));
230
231             Process proc;
232             try {
233                 proc = rt.exec(cmdArray);
234             } catch (Exception e) {
235                 logger.warn("An exception occurred while executing '{}' : '{}'", Arrays.asList(cmdArray),
236                         e.getMessage());
237                 updateState(RUN, OnOffType.OFF);
238                 updateState(OUTPUT, new StringType(e.getMessage()));
239                 return;
240             }
241
242             StringBuilder outputBuilder = new StringBuilder();
243             StringBuilder errorBuilder = new StringBuilder();
244
245             try (InputStreamReader isr = new InputStreamReader(proc.getInputStream());
246                     BufferedReader br = new BufferedReader(isr)) {
247                 String line;
248                 while ((line = br.readLine()) != null) {
249                     outputBuilder.append(line).append("\n");
250                     logger.debug("Exec [{}]: '{}'", "OUTPUT", line);
251                 }
252             } catch (IOException e) {
253                 logger.warn("An exception occurred while reading the stdout when executing '{}' : '{}'", commandLine,
254                         e.getMessage());
255             }
256
257             try (InputStreamReader isr = new InputStreamReader(proc.getErrorStream());
258                     BufferedReader br = new BufferedReader(isr)) {
259                 String line;
260                 while ((line = br.readLine()) != null) {
261                     errorBuilder.append(line).append("\n");
262                     logger.debug("Exec [{}]: '{}'", "ERROR", line);
263                 }
264             } catch (IOException e) {
265                 logger.warn("An exception occurred while reading the stderr when executing '{}' : '{}'", commandLine,
266                         e.getMessage());
267             }
268
269             boolean exitVal = false;
270             try {
271                 exitVal = proc.waitFor(timeOut, TimeUnit.MILLISECONDS);
272             } catch (InterruptedException e) {
273                 logger.warn("An exception occurred while waiting for the process ('{}') to finish : '{}'", commandLine,
274                         e.getMessage());
275             }
276
277             if (!exitVal) {
278                 logger.warn("Forcibly termininating the process ('{}') after a timeout of {} ms", commandLine, timeOut);
279                 proc.destroyForcibly();
280             }
281
282             updateState(RUN, OnOffType.OFF);
283             updateState(EXIT, new DecimalType(proc.exitValue()));
284
285             outputBuilder.append(errorBuilder.toString());
286
287             outputBuilder.append(errorBuilder.toString());
288
289             String transformedResponse = StringUtils.chomp(outputBuilder.toString());
290             String transformation = (String) getConfig().get(TRANSFORM);
291
292             if (transformation != null && transformation.length() > 0) {
293                 transformedResponse = transformResponse(transformedResponse, transformation);
294             }
295
296             updateState(OUTPUT, new StringType(transformedResponse));
297
298             DateTimeType stampType = new DateTimeType(ZonedDateTime.now());
299             updateState(LAST_EXECUTION, stampType);
300         }
301     }
302
303     protected @Nullable String transformResponse(String response, String transformation) {
304         String transformedResponse;
305
306         try {
307             String[] parts = splitTransformationConfig(transformation);
308             String transformationType = parts[0];
309             String transformationFunction = parts[1];
310
311             TransformationService transformationService = TransformationHelper.getTransformationService(bundleContext,
312                     transformationType);
313             if (transformationService != null) {
314                 transformedResponse = transformationService.transform(transformationFunction, response);
315             } else {
316                 transformedResponse = response;
317                 logger.warn("Couldn't transform response because transformationService of type '{}' is unavailable",
318                         transformationType);
319             }
320         } catch (TransformationException te) {
321             logger.warn("An exception occurred while transforming '{}' with '{}' : '{}'", response, transformation,
322                     te.getMessage());
323
324             // in case of an error we return the response without any transformation
325             transformedResponse = response;
326         }
327
328         logger.debug("Transformed response is '{}'", transformedResponse);
329         return transformedResponse;
330     }
331
332     /**
333      * Splits a transformation configuration string into its two parts - the
334      * transformation type and the function/pattern to apply.
335      *
336      * @param transformation the string to split
337      * @return a string array with exactly two entries for the type and the function
338      */
339     protected String[] splitTransformationConfig(String transformation) {
340         Matcher matcher = EXTRACT_FUNCTION_PATTERN.matcher(transformation);
341
342         if (!matcher.matches()) {
343             throw new IllegalArgumentException("given transformation function '" + transformation
344                     + "' does not follow the expected pattern '<function>(<pattern>)'");
345         }
346         matcher.reset();
347
348         matcher.find();
349         String type = matcher.group(1);
350         String pattern = matcher.group(2);
351
352         return new String[] { type, pattern };
353     }
354
355     /**
356      * Transforms the command string into an array.
357      * Either invokes the shell and passes using the "c" option
358      * or (if command already starts with one of the shells) splits by space.
359      *
360      * @param shell (path), picks to first one to execute the command
361      * @param cOption "c"-option string
362      * @param commandLine to execute
363      * @return command array
364      */
365     protected String[] createCmdArray(String[] shell, String cOption, String commandLine) {
366         boolean startsWithShell = false;
367         for (String sh : shell) {
368             if (commandLine.startsWith(sh + " ")) {
369                 startsWithShell = true;
370                 break;
371             }
372         }
373
374         if (!startsWithShell) {
375             return new String[] { shell[0], cOption, commandLine };
376         } else {
377             logger.debug("Splitting by spaces");
378             try {
379                 return commandLine.split(" ");
380             } catch (PatternSyntaxException e) {
381                 logger.warn("An exception occurred while splitting '{}' : '{}'", commandLine, e.getMessage());
382                 updateState(RUN, OnOffType.OFF);
383                 updateState(OUTPUT, new StringType(e.getMessage()));
384                 return new String[] {};
385             }
386         }
387     }
388
389     /**
390      * Contains information about which operating system openHAB is running on.
391      * Found on https://stackoverflow.com/a/31547504/7508309, slightly modified
392      *
393      * @author Constantin Piber (for Memin) - Initial contribution
394      */
395     public enum OS {
396         WINDOWS,
397         LINUX,
398         MAC,
399         SOLARIS,
400         UNKNOWN,
401         NOT_SET
402     }
403
404     private static OS os = OS.NOT_SET;
405
406     public static OS getOperatingSystemType() {
407         if (os == OS.NOT_SET) {
408             String operSys = System.getProperty("os.name").toLowerCase();
409             if (operSys.contains("win")) {
410                 os = OS.WINDOWS;
411             } else if (operSys.contains("nix") || operSys.contains("nux") || operSys.contains("aix")) {
412                 os = OS.LINUX;
413             } else if (operSys.contains("mac")) {
414                 os = OS.MAC;
415             } else if (operSys.contains("sunos")) {
416                 os = OS.SOLARIS;
417             } else {
418                 os = OS.UNKNOWN;
419             }
420         }
421         return os;
422     }
423
424     public static String getOperatingSystemName() {
425         return System.getProperty("os.name");
426     }
427 }