From 93ad7d6afa40a9b6b81acb3f0bde9464d55e1dcd Mon Sep 17 00:00:00 2001 From: Brian O'Connell Date: Sun, 29 Nov 2020 08:20:54 -0500 Subject: [PATCH] feat(jruby) JRuby scripting initial binding commit Signed-off-by: Brian O'Connell --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../NOTICE | 13 + .../README.md | 70 +++++ .../pom.xml | 35 +++ .../src/main/feature/feature.xml | 9 + .../JRubyScriptEngineConfiguration.java | 278 ++++++++++++++++++ .../internal/JRubyScriptEngineFactory.java | 119 ++++++++ .../jrubyscripting/internal/package-info.java | 21 ++ bundles/pom.xml | 1 + 10 files changed, 552 insertions(+) create mode 100644 bundles/org.openhab.automation.jrubyscripting/NOTICE create mode 100644 bundles/org.openhab.automation.jrubyscripting/README.md create mode 100644 bundles/org.openhab.automation.jrubyscripting/pom.xml create mode 100644 bundles/org.openhab.automation.jrubyscripting/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineConfiguration.java create mode 100644 bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineFactory.java create mode 100644 bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/package-info.java diff --git a/CODEOWNERS b/CODEOWNERS index a74a451fe9807..0b3df9522c307 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -138,6 +138,7 @@ /bundles/org.openhab.binding.ism8/ @hans-reiner /bundles/org.openhab.binding.jablotron/ @octa22 /bundles/org.openhab.binding.jeelink/ @vbier +/bundles/org.openhab.automation.jrubyscripting/ @boc-tothefuture /bundles/org.openhab.binding.kaleidescape/ @mlobstein /bundles/org.openhab.binding.keba/ @kgoderis /bundles/org.openhab.binding.km200/ @Markinus diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 67288274817e9..0a89dc6fbf32d 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -676,6 +676,11 @@ org.openhab.binding.jeelink ${project.version} + + org.openhab.addons.bundles + org.openhab.automation.jrubyscripting + ${project.version} + org.openhab.addons.bundles org.openhab.binding.kaleidescape diff --git a/bundles/org.openhab.automation.jrubyscripting/NOTICE b/bundles/org.openhab.automation.jrubyscripting/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.automation.jrubyscripting/README.md b/bundles/org.openhab.automation.jrubyscripting/README.md new file mode 100644 index 0000000000000..e453736c54669 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/README.md @@ -0,0 +1,70 @@ +# JRuby Scripting + +This add-on provides [JRuby](https://www.jruby.org/) 9.3.1 that can be used as a scripting language within automation rules and which eliminates the need to download JRuby and create `EXTRA_JAVA_OPTS` entries for `bootclasspath` and `rubylib`. + +## JRuby Scripting Configuration + +JRuby configuration parameters may be set by creating a jruby.cfg file in $OPENHAB_CONF/services/ + + +| Parameter | Default | Description | +|-------------------------------------------------------|--------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| org.openhab.automation.jrubyscripting:gem_home | $OPENHAB_CONF/automation/lib/ruby/gem_home | Location ruby gems will be installed and loaded from | +| org.openhab.automation.jrubyscripting:ruby_lib | $OPENHAB_CONF/automation/lib/ruby/ | Search path for libraries | +| org.openhab.automation.jrubyscripting:local_context | threadsafe | The local context holds Ruby runtime, name-value pairs for sharing variables between Java and Ruby. See [this](https://github.com/jruby/jruby/wiki/RedBridge#Context_Instance_Type) for options and details | +| org.openhab.automation.jrubyscripting:local_variables | transient | Defines how variables are shared between Ruby and Java. See [this](https://github.com/jruby/jruby/wiki/RedBridge#local-variable-behavior-options) for options and details | +| org.openhab.automation.jrubyscripting:gems | | Comma seperated list of [Ruby Gems](https://rubygems.org/) to install. | + + +## Ruby Gems + +This binding will install and make available on the library search path user specified gems. Gem versions may be specified using the standard ruby gem_name=version format. For example this configuration will install version 4 or higher of the OpenHAB JRuby Scripting Libray. + +```text +org.openhab.automation.jrubyscripting:gems=openhab-scripting=~>4.0 +``` + +## Creating JRuby Scripts + +When this add-on is installed, you can select JRuby as a scripting language when creating a script action within the rule editor of the UI. + +Alternatively, you can create scripts in the `automation/jsr223` configuration directory. +If you create an empty file called `test.rb`, you will see a log line with information similar to: + +```text + ... [INFO ] [.a.m.s.r.i.l.ScriptFileWatcher:150 ] - Loading script 'test.rb' +``` + +To enable debug logging, use the [console logging]({{base}}/administration/logging.html) commands to +enable debug logging for the automation functionality: + +```text +log:set DEBUG org.openhab.core.automation +log:set DEBUG org.openhab.binding.jrubyscripting +``` + +## Imports + +All [ScriptExtensions]({{base}}/configuration/jsr223.html#scriptextension-objects-all-jsr223-languages) are available in JRuby with the following exceptions/modifications: + +* The File variable, referencing java.io.File is not available as it conflicts with Ruby's File class preventing Ruby from initializing +* Globals scriptExtension, automationManager, ruleRegistry, items, voice, rules, things, events, itemRegistry, ir, actions, se, audio, lifecycleTracker are prepended with a $ (e.g. $automationManager) making them available as a global objects in Ruby. + + +## Script Examples + +JRuby scripts provide access to almost all the functionality in an openHAB runtime environment. +As a simple example, the following script logs "Hello, World!". +Note that `puts` will usually not work since the output has no terminal to display the text. +The openHAB server uses the [SLF4J](https://www.slf4j.org/) library for logging. + +```ruby +require 'java' +java_import org.slf4j.LoggerFactory + +LoggerFactory.getLogger("org.openhab.core.automation.examples").info("Hello world!") +``` + +JRuby can [import Java classes](https://github.com/jruby/jruby/wiki/CallingJavaFromJRuby). +Depending on the openHAB logging configuration, you may need to prefix logger names with `org.openhab.core.automation` for them to show up in the log file (or you modify the logging configuration). + diff --git a/bundles/org.openhab.automation.jrubyscripting/pom.xml b/bundles/org.openhab.automation.jrubyscripting/pom.xml new file mode 100644 index 0000000000000..1d150eae78137 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/pom.xml @@ -0,0 +1,35 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.2.0-SNAPSHOT + + + org.openhab.automation.jrubyscripting + + openHAB Add-ons :: Automation :: JRuby Scripting + + + + com.ibm.icu.*;resolution:=optional, + org.abego.treelayout.*;resolution:=optional, + org.apache.ivy.*;resolution:=optional, + org.stringtemplate.v4.*;resolution:=optional + 9.3.1.0 + + + + + org.jruby + jruby-complete + ${jruby.version} + compile + + + + diff --git a/bundles/org.openhab.automation.jrubyscripting/src/main/feature/feature.xml b/bundles/org.openhab.automation.jrubyscripting/src/main/feature/feature.xml new file mode 100644 index 0000000000000..2ef4f18277724 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.automation.jrubyscripting/${project.version} + + diff --git a/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineConfiguration.java b/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineConfiguration.java new file mode 100644 index 0000000000000..4e10fe82e9d32 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineConfiguration.java @@ -0,0 +1,278 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.jrubyscripting.internal; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.script.ScriptEngine; +import javax.script.ScriptEngineFactory; +import javax.script.ScriptException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.OpenHAB; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * + * Processes JRuby Configuration Parameters + * + * @author Brian O'Connell - Initial contribution + */ +@NonNullByDefault +public class JRubyScriptEngineConfiguration { + + private final Logger logger = LoggerFactory.getLogger(JRubyScriptEngineConfiguration.class); + + private final static Path DEFAULT_GEM_HOME = Paths.get(OpenHAB.getConfigFolder(), "automation", "lib", "ruby", + "gem_home"); + + // Map of configuration parameters + private final static Map CONFIGURATION_PARAMETERS = Map.ofEntries( + Map.entry("local_context", + new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.SYSTEM_PROPERTY) + .mappedTo("org.jruby.embed.localcontext.scope").defaultValue("threadsafe").build()), + + Map.entry("local_variable", + new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.SYSTEM_PROPERTY) + .mappedTo("org.jruby.embed.localvariable.behavior").defaultValue("transient").build()), + + Map.entry("gem_home", + new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT) + .mappedTo("GEM_HOME").defaultValue(DEFAULT_GEM_HOME.toString()).build()), + + Map.entry("rubylib", + new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT) + .mappedTo("RUBYLIB").defaultValue(DEFAULT_GEM_HOME.getParent().toString()).build()), + + Map.entry("gems", new OptionalConfigurationElement.Builder(OptionalConfigurationElement.Type.GEM).build())); + + private final static Map> CONFIGURATION_TYPE_MAP = CONFIGURATION_PARAMETERS + .values().stream().collect(Collectors.groupingBy(v -> v.type)); + + /** + * Update configuration + * + * @param config Configuration parameters to apply to ScripEngine + * @param factory ScriptEngineFactory to configure + */ + void update(Map config, ScriptEngineFactory factory) { + logger.trace("JRuby Script Engine Configuration: {}", config); + config.forEach(this::processConfigValue); + configureScriptEngine(factory); + } + + /** + * Apply configuration key/value to known configuration parameters + * + * @param key Configuration key + * @param value Configuration value + */ + private void processConfigValue(String key, Object value) { + OptionalConfigurationElement configurationElement = CONFIGURATION_PARAMETERS.get(key); + if (configurationElement != null) { + configurationElement.setValue(value.toString()); + } else { + logger.debug("Ignoring unexpected configuration key: {}", key); + } + } + + /** + * Configure the ScriptEngine + * + * @param factory Script Engine to configure + */ + void configureScriptEngine(ScriptEngineFactory factory) { + + configureSystemProperties(CONFIGURATION_TYPE_MAP.getOrDefault(OptionalConfigurationElement.Type.SYSTEM_PROPERTY, + Collections. emptyList())); + + ScriptEngine engine = factory.getScriptEngine(); + + configureRubyEnvironment(CONFIGURATION_TYPE_MAP.getOrDefault(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT, + Collections. emptyList()), engine); + configureGems(CONFIGURATION_TYPE_MAP.getOrDefault(OptionalConfigurationElement.Type.GEM, + Collections. emptyList()), engine); + } + + /** + * Install a gems in ScriptEngine + * + * @param optionalConfigurationElements List of gems to install + * @param engine Engine to install gems + */ + private synchronized void configureGems(List optionalConfigurationElements, + ScriptEngine engine) { + for (OptionalConfigurationElement configElement : optionalConfigurationElements) { + if (configElement.getValue().isPresent()) { + + String[] gems = configElement.getValue().get().split(","); + for (String gem : gems) { + gem = gem.trim(); + String gemCommand; + if (gem.contains("=")) { + String[] gemParts = gem.split("="); + gem = gemParts[0]; + String version = gemParts[1]; + gemCommand = "gem '" + gem + "', '" + version + "'"; + } else { + gemCommand = "gem '" + gem + "'"; + } + + // formatter turned off because it does not appropriately format multi-line strings + //@formatter:off + String gemInstallCode = + "require 'bundler/inline'\n" + + "gemfile do\n" + + "source 'https://rubygems.org'\n" + + gemCommand + "\n" + + "end"; + //@formatter:on + + try { + logger.debug("Installing Gem: {} ", gem); + logger.trace("Gem install code:\n{}\n", gemInstallCode); + engine.eval(gemInstallCode); + } catch (ScriptException e) { + logger.error("Error installing Gem", e); + } catch (Exception e) { // catch a general exception here so we don't break openhab + logger.error("Error installing Gem", e); + } + } + } else { + logger.debug("Ruby gem property has no value"); + } + } + } + + /** + * Configure the base Ruby Environment + * + * @param engine Engine to configure + */ + public ScriptEngine configureRubyEnvironment(ScriptEngine engine) { + configureRubyEnvironment(CONFIGURATION_TYPE_MAP.getOrDefault(OptionalConfigurationElement.Type.RUBY_ENVIRONMENT, + Collections. emptyList()), engine); + return engine; + } + + /** + * Configure the optional elements of the Ruby Environment + * + * @param optionalConfigurationElements Optional elements to configure in the ruby environment + * @param engine Engine in which to configure environment + */ + private void configureRubyEnvironment(List optionalConfigurationElements, + ScriptEngine engine) { + for (OptionalConfigurationElement configElement : optionalConfigurationElements) { + String environmentProperty = configElement.mappedTo().get(); + if (configElement.getValue().isPresent()) { + String environmentSetting = "ENV['" + environmentProperty + "']='" + configElement.getValue().get() + + "'"; + try { + logger.trace("Setting Ruby environment with code: {} ", environmentSetting); + engine.eval(environmentSetting); + } catch (ScriptException e) { + logger.error("Error setting ruby environment", e); + } + } else { + logger.debug("Ruby environment property ({}) has no value", environmentProperty); + } + } + } + + /** + * Configure system properties + * + * @param optionalConfigurationElements Optional system properties to configure + */ + private void configureSystemProperties(List optionalConfigurationElements) { + for (OptionalConfigurationElement configElement : optionalConfigurationElements) { + String systemProperty = configElement.mappedTo().get(); + if (configElement.getValue().isPresent()) { + String propertyValue = configElement.getValue().get(); + logger.trace("Setting system property ({}) to ({})", systemProperty, propertyValue); + System.setProperty(systemProperty, propertyValue); + } else { + logger.warn("System property ({}) has no value", systemProperty); + } + } + } + + /** + * Inner static companion class for configuration elements + */ + private static class OptionalConfigurationElement { + + private final Optional defaultValue; + private final Optional mappedTo; + private final Type type; + private Optional value; + + private OptionalConfigurationElement(Type type, @Nullable String mappedTo, @Nullable String defaultValue) { + this.type = type; + this.defaultValue = Optional.ofNullable(defaultValue); + this.mappedTo = Optional.ofNullable(mappedTo); + value = Optional.empty(); + } + + private Optional getValue() { + return value.or(() -> defaultValue); + } + + private void setValue(String value) { + this.value = Optional.of(value); + } + + private Optional mappedTo() { + return mappedTo; + } + + private enum Type { + SYSTEM_PROPERTY, + RUBY_ENVIRONMENT, + GEM + } + + private static class Builder { + private final Type type; + private @Nullable String defaultValue = null; + private @Nullable String mappedTo = null; + + private Builder(Type type) { + this.type = type; + } + + private Builder mappedTo(String mappedTo) { + this.mappedTo = mappedTo; + return this; + } + + private Builder defaultValue(String value) { + this.defaultValue = value; + return this; + } + + private OptionalConfigurationElement build() { + return new OptionalConfigurationElement(type, mappedTo, defaultValue); + } + } + } +} diff --git a/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineFactory.java b/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineFactory.java new file mode 100644 index 0000000000000..b7c28d86e87a0 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/JRubyScriptEngineFactory.java @@ -0,0 +1,119 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.jrubyscripting.internal; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.script.ScriptEngine; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.automation.module.script.AbstractScriptEngineFactory; +import org.openhab.core.automation.module.script.ScriptEngineFactory; +import org.osgi.service.component.ComponentContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Modified; + +/** + * This is an implementation of a {@link ScriptEngineFactory} for Ruby. + * handlers. + * + * @author Brian O'Connell - Initial contribution + */ +@NonNullByDefault +@Component(service = ScriptEngineFactory.class, configurationPid = "org.openhab.automation.jrubyscripting") +public class JRubyScriptEngineFactory extends AbstractScriptEngineFactory { + + private final JRubyScriptEngineConfiguration configuration = new JRubyScriptEngineConfiguration(); + + // Filter out the File entry to prevent shadowing the Ruby File class which breaks Ruby in spectacularly + // difficult ways to debug. + private final static Set FILTERED_PRESETS = Set.of("File"); + private final static Set INSTANCE_PRESETS = Set.of(); + private final static Set GLOBAL_PRESETS = Set.of("scriptExtension", "automationManager", "ruleRegistry", + "items", "voice", "rules", "things", "events", "itemRegistry", "ir", "actions", "se", "audio", + "lifecycleTracker"); + + private final javax.script.ScriptEngineFactory factory = new org.jruby.embed.jsr223.JRubyEngineFactory(); + + // formatter turned off because it does not appropriately format chained streams + //@formatter:off + private final List scriptTypes = Stream.concat(factory.getExtensions().stream(), + factory.getMimeTypes().stream()) + .collect(Collectors.toUnmodifiableList()); + //@formatter:on + + // Adds @ in front of a set of variables so that Ruby recogonizes them as instance variables + private static Map.Entry mapInstancePresets(Map.Entry entry) { + if (INSTANCE_PRESETS.contains(entry.getKey())) { + return Map.entry("@" + entry.getKey(), entry.getValue()); + } else { + return entry; + } + } + + // Adds $ in front of a set of variables so that Ruby recogonizes them as global variables + private static Map.Entry mapGlobalPresets(Map.Entry entry) { + if (GLOBAL_PRESETS.contains(entry.getKey())) { + return Map.entry("$" + entry.getKey(), entry.getValue()); + } else { + return entry; + } + } + + // The activate call is activate binding and set the bindings configuration + @Activate + protected void activate(ComponentContext componentContext, Map config) { + configuration.update(config, factory); + } + + // The modified call updates configuration for binding + @Modified + protected void modified(Map config) { + configuration.update(config, factory); + } + + @Override + public List getScriptTypes() { + return scriptTypes; + } + + @Override + public void scopeValues(ScriptEngine scriptEngine, Map scopeValues) { + + // formatter turned off because it does not appropriately format chained streams + //@formatter:off + Map filteredScopeValues = + scopeValues + .entrySet() + .stream() + .filter(map -> !FILTERED_PRESETS.contains(map.getKey())) + .map(JRubyScriptEngineFactory::mapInstancePresets) + .map(JRubyScriptEngineFactory::mapGlobalPresets) + .collect(Collectors.toMap(map -> map.getKey(), map -> map.getValue())); + //@formatter:on + + super.scopeValues(scriptEngine, filteredScopeValues); + } + + @Override + public @Nullable ScriptEngine createScriptEngine(String scriptType) { + return scriptTypes.contains(scriptType) ? configuration.configureRubyEnvironment(factory.getScriptEngine()) + : null; + } +} diff --git a/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/package-info.java b/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/package-info.java new file mode 100644 index 0000000000000..1e0e3358e3e65 --- /dev/null +++ b/bundles/org.openhab.automation.jrubyscripting/src/main/java/org/openhab/binding/jrubyscripting/internal/package-info.java @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +@org.osgi.annotation.bundle.Header(name = org.osgi.framework.Constants.DYNAMICIMPORT_PACKAGE, value = "*") +package org.openhab.binding.jrubyscripting.internal; + +/** + * Additional information for the Groovy Scripting package + * + * @author Brian O'Connell - Initial contribution + */ diff --git a/bundles/pom.xml b/bundles/pom.xml index 345fc9daecd35..aee3db282c397 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -21,6 +21,7 @@ org.openhab.automation.groovyscripting org.openhab.automation.jsscripting org.openhab.automation.jythonscripting + org.openhab.automation.jrubyscripting org.openhab.automation.pidcontroller org.openhab.automation.pwm