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