2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.exec.internal.handler;
15 import static org.openhab.binding.exec.internal.ExecBindingConstants.*;
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;
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;
56 * The {@link ExecHandler} is responsible for handling commands, which are
57 * sent to one of the channels.
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
64 public class ExecHandler extends BaseThingHandler {
66 * Use this to separate between command and parameter, and also between parameters.
68 public static final String CMD_LINE_DELIMITER = "@@";
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;
77 private Logger logger = LoggerFactory.getLogger(ExecHandler.class);
79 private final BundleContext bundleContext;
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";
88 // RegEx to extract a parse a function String <code>'(.*?)\((.*)\)'</code>
89 private static final Pattern EXTRACT_FUNCTION_PATTERN = Pattern.compile("(.*?)\\((.*)\\)");
91 private @Nullable ScheduledFuture<?> executionJob;
92 private @Nullable String lastInput;
94 private static Runtime rt = Runtime.getRuntime();
96 public ExecHandler(Thing thing, ExecWhitelistWatchService execWhitelistWatchService) {
98 this.bundleContext = FrameworkUtil.getBundle(ExecHandler.class).getBundleContext();
99 this.execWhitelistWatchService = execWhitelistWatchService;
103 public void handleCommand(ChannelUID channelUID, Command command) {
104 if (command instanceof RefreshType) {
105 // Placeholder for later refinement
107 if (channelUID.getId().equals(RUN)) {
108 if (command instanceof OnOffType) {
109 if (command == OnOffType.ON) {
110 scheduler.schedule(this::execute, 0, TimeUnit.SECONDS);
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);
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);
138 updateStatus(ThingStatus.ONLINE);
142 public void dispose() {
143 if (executionJob != null && !executionJob.isCancelled()) {
144 executionJob.cancel(true);
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);
157 if ((getConfig().get(TIME_OUT)) != null) {
158 timeOut = ((BigDecimal) getConfig().get(TIME_OUT)).intValue() * 1000;
161 if (commandLine != null && !commandLine.isEmpty()) {
162 updateState(RUN, OnOffType.ON);
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.
173 Date date = Calendar.getInstance().getTime();
175 if (lastInput != null) {
176 commandLine = String.format(commandLine, date, lastInput);
178 commandLine = String.format(commandLine, date);
180 } catch (IllegalFormatException e) {
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()));
191 if (commandLine.contains(CMD_LINE_DELIMITER)) {
192 logger.debug("Splitting by '{}'", CMD_LINE_DELIMITER);
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()));
202 // Invoke shell with 'c' option and pass string
203 logger.debug("Passing to shell for parsing command.");
204 switch (getOperatingSystemType()) {
206 shell = SHELL_WINDOWS;
207 logger.debug("OS: WINDOWS ({})", getOperatingSystemName());
208 cmdArray = createCmdArray(shell, "/c", commandLine);
214 // assume sh is present, should all be POSIX-compliant
216 logger.debug("OS: *NIX ({})", getOperatingSystemName());
217 cmdArray = createCmdArray(shell, "-c", commandLine);
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!"));
228 if (cmdArray.length == 0) {
229 logger.trace("Empty command received, not executing");
233 logger.trace("The command to be executed will be '{}'", Arrays.asList(cmdArray));
237 proc = rt.exec(cmdArray);
238 } catch (Exception e) {
239 logger.warn("An exception occurred while executing '{}' : '{}'", Arrays.asList(cmdArray),
241 updateState(RUN, OnOffType.OFF);
242 updateState(OUTPUT, new StringType(e.getMessage()));
246 StringBuilder outputBuilder = new StringBuilder();
247 StringBuilder errorBuilder = new StringBuilder();
249 try (InputStreamReader isr = new InputStreamReader(proc.getInputStream());
250 BufferedReader br = new BufferedReader(isr)) {
252 while ((line = br.readLine()) != null) {
253 outputBuilder.append(line).append("\n");
254 logger.debug("Exec [{}]: '{}'", "OUTPUT", line);
256 } catch (IOException e) {
257 logger.warn("An exception occurred while reading the stdout when executing '{}' : '{}'", commandLine,
261 try (InputStreamReader isr = new InputStreamReader(proc.getErrorStream());
262 BufferedReader br = new BufferedReader(isr)) {
264 while ((line = br.readLine()) != null) {
265 errorBuilder.append(line).append("\n");
266 logger.debug("Exec [{}]: '{}'", "ERROR", line);
268 } catch (IOException e) {
269 logger.warn("An exception occurred while reading the stderr when executing '{}' : '{}'", commandLine,
273 boolean exitVal = false;
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,
282 logger.warn("Forcibly termininating the process ('{}') after a timeout of {} ms", commandLine, timeOut);
283 proc.destroyForcibly();
286 updateState(RUN, OnOffType.OFF);
287 updateState(EXIT, new DecimalType(proc.exitValue()));
289 outputBuilder.append(errorBuilder.toString());
291 outputBuilder.append(errorBuilder.toString());
293 String transformedResponse = Objects.requireNonNull(StringUtils.chomp(outputBuilder.toString()));
294 String transformation = (String) getConfig().get(TRANSFORM);
296 if (transformation != null && transformation.length() > 0) {
297 transformedResponse = transformResponse(transformedResponse, transformation);
300 updateState(OUTPUT, new StringType(transformedResponse));
302 DateTimeType stampType = new DateTimeType(ZonedDateTime.now());
303 updateState(LAST_EXECUTION, stampType);
307 protected @Nullable String transformResponse(String response, String transformation) {
308 String transformedResponse;
311 String[] parts = splitTransformationConfig(transformation);
312 String transformationType = parts[0];
313 String transformationFunction = parts[1];
315 TransformationService transformationService = TransformationHelper.getTransformationService(bundleContext,
317 if (transformationService != null) {
318 transformedResponse = transformationService.transform(transformationFunction, response);
320 transformedResponse = response;
321 logger.warn("Couldn't transform response because transformationService of type '{}' is unavailable",
324 } catch (TransformationException te) {
325 logger.warn("An exception occurred while transforming '{}' with '{}' : '{}'", response, transformation,
328 // in case of an error we return the response without any transformation
329 transformedResponse = response;
332 logger.debug("Transformed response is '{}'", transformedResponse);
333 return transformedResponse;
337 * Splits a transformation configuration string into its two parts - the
338 * transformation type and the function/pattern to apply.
340 * @param transformation the string to split
341 * @return a string array with exactly two entries for the type and the function
343 protected String[] splitTransformationConfig(String transformation) {
344 Matcher matcher = EXTRACT_FUNCTION_PATTERN.matcher(transformation);
346 if (!matcher.matches()) {
347 throw new IllegalArgumentException("given transformation function '" + transformation
348 + "' does not follow the expected pattern '<function>(<pattern>)'");
353 String type = matcher.group(1);
354 String pattern = matcher.group(2);
356 return new String[] { type, pattern };
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.
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
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;
378 if (!startsWithShell) {
379 return new String[] { shell[0], cOption, commandLine };
381 logger.debug("Splitting by spaces");
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[] {};
394 * Contains information about which operating system openHAB is running on.
395 * Found on https://stackoverflow.com/a/31547504/7508309, slightly modified
397 * @author Constantin Piber (for Memin) - Initial contribution
409 private static OS os = OS.NOT_SET;
411 public static OS getOperatingSystemType() {
412 if (os == OS.NOT_SET) {
413 String operSys = System.getProperty("os.name").toLowerCase();
414 if (operSys.contains("win")) {
416 } else if (operSys.contains("nix") || operSys.contains("nux") || operSys.contains("aix")) {
418 } else if (operSys.endsWith("bsd")) {
420 } else if (operSys.contains("mac")) {
422 } else if (operSys.contains("sunos")) {
431 public static String getOperatingSystemName() {
432 String osname = System.getProperty("os.name");
433 return osname != null ? osname : "unknown";