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