diff --git a/bundles/org.openhab.automation.jsscripting/pom.xml b/bundles/org.openhab.automation.jsscripting/pom.xml index 61b1912a72a1b..66f5bfff945ee 100644 --- a/bundles/org.openhab.automation.jsscripting/pom.xml +++ b/bundles/org.openhab.automation.jsscripting/pom.xml @@ -20,7 +20,7 @@ org.openhab.addons.bundles org.openhab.addons.reactor.bundles - 3.0.0-SNAPSHOT + 3.1.0-SNAPSHOT org.openhab.automation.jsscripting diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/ExtScriptFileWatcher.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/ExtScriptFileWatcher.java new file mode 100644 index 0000000000000..b4668b8fd6c8f --- /dev/null +++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/ExtScriptFileWatcher.java @@ -0,0 +1,327 @@ +// Hello +package org.openhab.automation.jsscripting.internal; + +import static java.nio.file.StandardWatchEventKinds.ENTRY_CREATE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_DELETE; +import static java.nio.file.StandardWatchEventKinds.ENTRY_MODIFY; + +import java.io.BufferedInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.MalformedURLException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.WatchEvent; +import java.nio.file.WatchEvent.Kind; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNull; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.OpenHAB; +import org.openhab.core.automation.module.script.ScriptEngineContainer; +import org.openhab.core.automation.module.script.ScriptEngineManager; +import org.openhab.core.automation.module.script.rulesupport.internal.loader.ScriptFileWatcher; +import org.openhab.core.common.NamedThreadFactory; +import org.openhab.core.service.AbstractWatchService; +import org.openhab.core.service.ReadyMarker; +import org.openhab.core.service.ReadyMarkerFilter; +import org.openhab.core.service.ReadyService; +import org.openhab.core.service.ReadyService.ReadyTracker; +import org.openhab.core.service.StartLevelService; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Deactivate; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link ScriptFileWatcher} watches the jsr223 directory for files. If a + * new/modified file is detected, the script is read and passed to the + * {@link ScriptEngineManager}. + * + * @author Simon Merschjohann - Initial contribution + * @author Kai Kreuzer - improved logging and removed thread pool + */ +@Component(immediate = true) +public class ExtScriptFileWatcher extends AbstractWatchService implements ReadyTracker { + + private static final Set EXCLUDED_FILE_EXTENSIONS = new HashSet<>( + Arrays.asList("txt", "old", "example", "backup", "md", "swp", "tmp", "bak")); + + public static final String FILE_DIRECTORY = "automation" + File.separator + "jsr223-node"; + private static final long RECHECK_INTERVAL = 20; + + private boolean started = false; + + private final ScriptEngineManager manager; + private final ReadyService readyService; + private @Nullable ScheduledExecutorService scheduler; + + private final Map> urlsByScriptExtension = new ConcurrentHashMap<>(); + private final Set loaded = new HashSet<>(); + + @Activate + public ExtScriptFileWatcher(final @Reference ScriptEngineManager manager, + final @Reference ReadyService readyService) { + super(OpenHAB.getConfigFolder() + File.separator + FILE_DIRECTORY); + this.manager = manager; + this.readyService = readyService; + } + + @Activate + @Override + public void activate() { + super.activate(); + readyService.registerTracker(this, new ReadyMarkerFilter().withType(StartLevelService.STARTLEVEL_MARKER_TYPE) + .withIdentifier(Integer.toString(StartLevelService.STARTLEVEL_MODEL))); + + File f = new File(super.pathToWatch); + if (!f.isDirectory()) { + logger.warn("Missing directory '{}' for extended JSR223 support", super.pathToWatch); + } + } + + @Deactivate + @Override + public void deactivate() { + logger.info("Stopping ..."); + readyService.unregisterTracker(this); + ScheduledExecutorService localScheduler = scheduler; + if (localScheduler != null) { + localScheduler.shutdownNow(); + scheduler = null; + } + super.deactivate(); + } + + /** + * Imports resources from the specified file or directory. + * + * @param file the file or directory to import resources from + */ + private void importResources(File file) { + logger.info("importResources ..." + file.getAbsolutePath()); + if (file.exists()) { + File[] files = file.listFiles(); + if (files != null) { + for (File f : files) { + if (!f.isHidden() && !f.isDirectory()) { + importResources(f); + } + } + } else { + try { + URL url = file.toURI().toURL(); + importFile(url); + } catch (MalformedURLException e) { + // can't happen for the 'file' protocol handler with a correctly formatted URI + logger.debug("Can't create a URL", e); + } + } + } + } + + @Override + protected boolean watchSubDirectories() { + return false; + } + + @Override + protected Kind[] getWatchEventKinds(Path subDir) { + return new Kind[] { ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY }; + } + + @Override + protected void processWatchEvent(WatchEvent event, Kind kind, Path path) { + File root = new File(pathToWatch); + File file = path.toFile(); + if (!file.isHidden()) { + + // if the changed file is not a root file, reload all files + if (!file.getParent().equals(root.getPath())) { + // reloadAllFiles(); + System.out.println("SKIP FILE -------------> " + file.getAbsolutePath()); + return; + } + + try { + URL fileUrl = file.toURI().toURL(); + if (ENTRY_DELETE.equals(kind)) { + removeFile(fileUrl); + } + + if (file.canRead() && (ENTRY_CREATE.equals(kind) || ENTRY_MODIFY.equals(kind))) { + importFile(fileUrl); + } + } catch (MalformedURLException e) { + logger.error("malformed", e); + } + } + } + + private void removeFile(URL url) { + dequeueUrl(url); + manager.removeEngine(getScriptIdentifier(url)); + loaded.remove(url); + } + + private synchronized void importFile(URL url) { + String fileName = url.getFile(); + if (loaded.contains(url)) { + this.removeFile(url); // if already loaded, remove first + } + + String scriptType = getScriptType(url); + if (scriptType != null) { + if (!started) { + enqueueUrl(url, scriptType); + } else { + if (manager.isSupported(scriptType)) { + try (InputStreamReader reader = new InputStreamReader(new BufferedInputStream(url.openStream()), + StandardCharsets.UTF_8)) { + logger.info("Loading script '{}'", fileName); + + ScriptEngineContainer container = manager.createScriptEngine(scriptType, + getScriptIdentifier(url)); + + if (container != null) { + manager.loadScript(container.getIdentifier(), reader); + loaded.add(url); + logger.debug("Script loaded: {}", fileName); + } else { + logger.error("Script loading error, ignoring file: {}", fileName); + } + } catch (IOException e) { + logger.error("Failed to load file '{}': {}", url.getFile(), e.getMessage()); + } + } else { + enqueueUrl(url, scriptType); + logger.info("ScriptEngine for {} not available", scriptType); + } + } + } + } + + private void enqueueUrl(URL url, String scriptType) { + synchronized (urlsByScriptExtension) { + Set set = urlsByScriptExtension.get(scriptType); + if (set == null) { + set = new HashSet<>(); + urlsByScriptExtension.put(scriptType, set); + } + set.add(url); + logger.debug("in queue: {}", urlsByScriptExtension); + } + } + + private void dequeueUrl(URL url) { + String scriptType = getScriptType(url); + if (scriptType != null) { + synchronized (urlsByScriptExtension) { + Set set = urlsByScriptExtension.get(scriptType); + if (set != null) { + set.remove(url); + if (set.isEmpty()) { + urlsByScriptExtension.remove(scriptType); + } + } + logger.debug("in queue: {}", urlsByScriptExtension); + } + } + } + + private @Nullable String getScriptType(URL url) { + logger.info("getScriptType ..."); + String fileName = url.getPath(); + int index = fileName.lastIndexOf("."); + if (index == -1) { + return null; + } + String fileExtension = fileName.substring(index + 1); + + // ignore known file extensions for "temp" files + if (EXCLUDED_FILE_EXTENSIONS.contains(fileExtension) || fileExtension.endsWith("~")) { + return null; + } + return fileExtension; + } + + private String getScriptIdentifier(URL url) { + return url.toString(); + } + + private void checkFiles() { + SortedSet reimportUrls = new TreeSet(new Comparator() { + @Override + public int compare(URL o1, URL o2) { + try { + Path path1 = Paths.get(o1.toURI()); + String name1 = path1.getFileName().toString(); + logger.trace("o1 [{}], path1 [{}], name1 [{}]", o1, path1, name1); + + Path path2 = Paths.get(o2.toURI()); + String name2 = path2.getFileName().toString(); + logger.trace("o2 [{}], path2 [{}], name2 [{}]", o2, path2, name2); + + int nameCompare = name1.compareToIgnoreCase(name2); + if (nameCompare != 0) { + return nameCompare; + } else { + int pathCompare = path1.getParent().toString() + .compareToIgnoreCase(path2.getParent().toString()); + return pathCompare; + } + } catch (URISyntaxException e) { + logger.error("URI syntax exception", e); + return 0; + } + } + }); + + synchronized (urlsByScriptExtension) { + Set newlySupported = new HashSet<>(); + for (String key : urlsByScriptExtension.keySet()) { + if (manager.isSupported(key)) { + newlySupported.add(key); + } + } + + for (String key : newlySupported) { + reimportUrls.addAll(Objects.requireNonNullElse(urlsByScriptExtension.remove(key), Set.of())); + } + } + + for (URL url : reimportUrls) { + importFile(url); + } + } + + @Override + public void onReadyMarkerAdded(@NonNull ReadyMarker readyMarker) { + started = true; + ScheduledExecutorService localScheduler = Executors + .newSingleThreadScheduledExecutor(new NamedThreadFactory("ext-scriptwatcher")); + scheduler = localScheduler; + localScheduler.submit(() -> importResources(new File(pathToWatch))); + localScheduler.scheduleWithFixedDelay(this::checkFiles, RECHECK_INTERVAL, RECHECK_INTERVAL, TimeUnit.SECONDS); + } + + @Override + public void onReadyMarkerRemoved(@NonNull ReadyMarker readyMarker) { + started = false; + } +} diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java index 2a5682636a94d..761fd5bd1c05f 100644 --- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java +++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/GraalJSScriptEngineFactory.java @@ -13,9 +13,10 @@ package org.openhab.automation.jsscripting.internal; import java.util.*; - +import java.io.File; import javax.script.ScriptEngine; +import org.openhab.core.OpenHAB; import org.openhab.core.automation.module.script.ScriptEngineFactory; import org.osgi.service.component.annotations.Component; @@ -47,7 +48,13 @@ public void scopeValues(ScriptEngine scriptEngine, Map scopeValu @Override public ScriptEngine createScriptEngine(String scriptType) { - OpenhabGraalJSScriptEngine engine = new OpenhabGraalJSScriptEngine(); + + // final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "lib", + // "javascript", "personal"); + + final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "jsr223-ts"); + + OpenhabGraalJSScriptEngine engine = new OpenhabGraalJSScriptEngine(MODULE_DIR); return new DebuggingGraalScriptEngine<>(engine); } } diff --git a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java index 39623c50a8c75..4211a2d43c39a 100644 --- a/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java +++ b/bundles/org.openhab.automation.jsscripting/src/main/java/org/openhab/automation/jsscripting/internal/OpenhabGraalJSScriptEngine.java @@ -50,8 +50,8 @@ public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngi private static final Logger logger = LoggerFactory.getLogger(OpenhabGraalJSScriptEngine.class); private static final String REQUIRE_WRAPPER_NAME = "__wraprequire__"; - private static final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "lib", - "javascript", "personal"); + // private static final String MODULE_DIR = String.join(File.separator, OpenHAB.getConfigFolder(), "automation", "lib", + // "javascript", "personal"); // these fields start as null because they are populated on first use @NonNullByDefault({}) @@ -65,11 +65,12 @@ public class OpenhabGraalJSScriptEngine extends InvocationInterceptingScriptEngi * Creates an implementation of ScriptEngine (& Invocable), wrapping the contained engine, that tracks the script * lifecycle and provides hooks for scripts to do so too. */ - public OpenhabGraalJSScriptEngine() { + public OpenhabGraalJSScriptEngine(String moduleDir) { super(null); // delegate depends on fields not yet initialised, so we cannot set it immediately + delegate = GraalJSScriptEngine.create(null, Context.newBuilder("js").allowExperimentalOptions(true).allowAllAccess(true) - .option("js.commonjs-require-cwd", MODULE_DIR).option("js.nashorn-compat", "true") // to ease + .option("js.commonjs-require-cwd", moduleDir).option("js.nashorn-compat", "true") // to ease // migration .option("js.commonjs-require", "true") // enable CommonJS module support .fileSystem(new DelegatingFileSystem(FileSystems.getDefault().provider()) {