Skip to content

Commit

Permalink
Add dynamic scripting language transformation service
Browse files Browse the repository at this point in the history
This replaced SCRIPT transformation with one specific to each language

e.g. JS, RB, GROOVY, etc.

Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au>
  • Loading branch information
jimtng committed Mar 26, 2023
1 parent ae89579 commit 1bbc292
Show file tree
Hide file tree
Showing 18 changed files with 324 additions and 218 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,8 @@
*/
package org.openhab.core.automation.module.script;

import java.net.URI;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
Expand All @@ -24,7 +22,6 @@
import java.util.concurrent.locks.ReentrantLock;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import javax.script.Compilable;
import javax.script.CompiledScript;
Expand All @@ -34,22 +31,12 @@

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.automation.module.script.internal.ScriptEngineFactoryHelper;
import org.openhab.core.automation.module.script.profile.ScriptProfile;
import org.openhab.core.common.ThreadPoolManager;
import org.openhab.core.common.registry.RegistryChangeListener;
import org.openhab.core.config.core.ConfigOptionProvider;
import org.openhab.core.config.core.ParameterOption;
import org.openhab.core.transform.Transformation;
import org.openhab.core.transform.TransformationException;
import org.openhab.core.transform.TransformationRegistry;
import org.openhab.core.transform.TransformationService;
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;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -59,39 +46,34 @@
*
* @author Jan N. Klug - Initial contribution
*/
@Component(service = { TransformationService.class, ScriptTransformationService.class,
ConfigOptionProvider.class }, property = { "openhab.transform=SCRIPT" })
@NonNullByDefault
public class ScriptTransformationService
implements TransformationService, RegistryChangeListener<Transformation>, ConfigOptionProvider {
public class ScriptTransformationService implements TransformationService, RegistryChangeListener<Transformation> {
public static final String OPENHAB_TRANSFORMATION_SCRIPT = "openhab-transformation-script-";
private static final String PROFILE_CONFIG_URI = "profile:transform:SCRIPT";
public static final String SUPPORTED_CONFIGURATION_TYPE = "script";

private static final Pattern SCRIPT_CONFIG_PATTERN = Pattern
.compile("(?<scriptType>.*?):(?<scriptUid>.*?)(\\?(?<params>.*?))?");
private static final Pattern INLINE_SCRIPT_CONFIG_PATTERN = Pattern.compile("\\|(?<inlineScript>.+)");

private static final Pattern SCRIPT_CONFIG_PATTERN = Pattern.compile("(?<scriptUid>.+?)(\\?(?<params>.*?))?");

private final Logger logger = LoggerFactory.getLogger(ScriptTransformationService.class);

private final ScheduledExecutorService scheduler = ThreadPoolManager
.getScheduledPool(ThreadPoolManager.THREAD_POOL_NAME_COMMON);

private final String scriptType;

private final Map<String, ScriptRecord> scriptCache = new ConcurrentHashMap<>();

private final TransformationRegistry transformationRegistry;
private final Map<String, String> supportedScriptTypes = new ConcurrentHashMap<>();

private final ScriptEngineManager scriptEngineManager;

@Activate
public ScriptTransformationService(@Reference TransformationRegistry transformationRegistry,
@Reference ScriptEngineManager scriptEngineManager) {
public ScriptTransformationService(final String scriptType, TransformationRegistry transformationRegistry,
ScriptEngineManager scriptEngineManager) {
this.transformationRegistry = transformationRegistry;
this.scriptEngineManager = scriptEngineManager;
this.scriptType = scriptType;
transformationRegistry.addRegistryChangeListener(this);
}

@Deactivate
public void deactivate() {
transformationRegistry.removeRegistryChangeListener(this);

Expand All @@ -101,28 +83,34 @@ public void deactivate() {

@Override
public @Nullable String transform(String function, String source) throws TransformationException {
Matcher configMatcher = SCRIPT_CONFIG_PATTERN.matcher(function);
if (!configMatcher.matches()) {
throw new TransformationException("Script Type must be prepended to transformation UID.");
String scriptUid;
String inlineScript = null;
String params = null;

Matcher configMatcher = INLINE_SCRIPT_CONFIG_PATTERN.matcher(function);
if (configMatcher.matches()) {
inlineScript = configMatcher.group("inlineScript");
// prefix with | to avoid clashing with a real filename
scriptUid = "|" + Integer.toString(inlineScript.hashCode());
} else {
configMatcher = SCRIPT_CONFIG_PATTERN.matcher(function);
if (!configMatcher.matches()) {
throw new TransformationException("Invalid syntax for the script transformation: '" + function + "'");
}
scriptUid = configMatcher.group("scriptUid");
params = configMatcher.group("params");
}
String scriptType = configMatcher.group("scriptType");
String scriptUid = configMatcher.group("scriptUid");

ScriptRecord scriptRecord = scriptCache.computeIfAbsent(scriptUid, k -> new ScriptRecord());
scriptRecord.lock.lock();
try {
if (scriptRecord.script.isBlank()) {
if (scriptUid.startsWith("|")) {
// inline script -> strip inline-identifier
scriptRecord.script = scriptUid.substring(1);
if (inlineScript != null) {
scriptRecord.script = inlineScript;
} else {
// get script from transformation registry
Transformation transformation = transformationRegistry.get(scriptUid);
if (transformation != null) {
if (!SUPPORTED_CONFIGURATION_TYPE.equals(transformation.getType())) {
throw new TransformationException("Configuration does not have correct type 'script' but '"
+ transformation.getType() + "'.");
}
scriptRecord.script = transformation.getConfiguration().getOrDefault(Transformation.FUNCTION,
"");
}
Expand Down Expand Up @@ -160,7 +148,6 @@ public void deactivate() {
ScriptContext executionContext = engine.getContext();
executionContext.setAttribute("input", source, ScriptContext.ENGINE_SCOPE);

String params = configMatcher.group("params");
if (params != null) {
for (String param : params.split("&")) {
String[] splitString = param.split("=");
Expand All @@ -169,7 +156,9 @@ public void deactivate() {
"Parameter '{}' does not consist of two parts for configuration UID {}, skipping.",
param, scriptUid);
} else {
executionContext.setAttribute(splitString[0], splitString[1], ScriptContext.ENGINE_SCOPE);
param = URLDecoder.decode(splitString[0], StandardCharsets.UTF_8);
String value = URLDecoder.decode(splitString[1], StandardCharsets.UTF_8);
executionContext.setAttribute(param, value, ScriptContext.ENGINE_SCOPE);
}
}
}
Expand Down Expand Up @@ -243,38 +232,6 @@ private void disposeScriptEngine(ScriptEngine scriptEngine) {
}
}

@Override
public @Nullable Collection<ParameterOption> getParameterOptions(URI uri, String param, @Nullable String context,
@Nullable Locale locale) {
if (PROFILE_CONFIG_URI.equals(uri.toString())) {
if (ScriptProfile.CONFIG_TO_HANDLER_SCRIPT.equals(param)
|| ScriptProfile.CONFIG_TO_ITEM_SCRIPT.equals(param)) {
return transformationRegistry.getTransformations(List.of(SUPPORTED_CONFIGURATION_TYPE)).stream()
.map(c -> new ParameterOption(c.getUID(), c.getLabel())).collect(Collectors.toList());
}
if (ScriptProfile.CONFIG_SCRIPT_LANGUAGE.equals(param)) {
return supportedScriptTypes.entrySet().stream().map(e -> new ParameterOption(e.getKey(), e.getValue()))
.collect(Collectors.toList());
}
}
return null;
}

/**
* As {@link ScriptEngineFactory}s are added/removed, this method will cache all available script types
*/
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
public void setScriptEngineFactory(ScriptEngineFactory engineFactory) {
Map.Entry<String, String> parameterOption = ScriptEngineFactoryHelper.getParameterOption(engineFactory);
if (parameterOption != null) {
supportedScriptTypes.put(parameterOption.getKey(), parameterOption.getValue());
}
}

public void unsetScriptEngineFactory(ScriptEngineFactory engineFactory) {
supportedScriptTypes.remove(ScriptEngineFactoryHelper.getPreferredMimeType(engineFactory));
}

private static class ScriptRecord {
public String script = "";
public @Nullable ScriptEngineContainer scriptEngineContainer;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* Copyright (c) 2010-2023 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.core.automation.module.script;

import java.util.Dictionary;
import java.util.Hashtable;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.automation.module.script.internal.ScriptEngineFactoryHelper;
import org.openhab.core.transform.TransformationRegistry;
import org.openhab.core.transform.TransformationService;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
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;
import org.osgi.service.component.annotations.ReferenceCardinality;
import org.osgi.service.component.annotations.ReferencePolicy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The {@link ScriptTransformationServiceFactory} registers a {@link ScriptTransformationService}
* for each newly added script engine.
*
* @author Jimmy Tanagra - Initial contribution
*/
@Component(immediate = true, service = ScriptTransformationServiceFactory.class)
@NonNullByDefault
public class ScriptTransformationServiceFactory {
private final Logger logger = LoggerFactory.getLogger(ScriptTransformationServiceFactory.class);

private final TransformationRegistry transformationRegistry;
private final ScriptEngineManager scriptEngineManager;
private final BundleContext bundleContext;

private final Map<String, ServiceRegistration> scriptTransformations = new ConcurrentHashMap<>();

@Activate
public ScriptTransformationServiceFactory(@Reference TransformationRegistry transformationRegistry,
@Reference ScriptEngineManager scriptEngineManager, BundleContext bundleContext) {
this.bundleContext = bundleContext;
this.transformationRegistry = transformationRegistry;
this.scriptEngineManager = scriptEngineManager;
}

@Deactivate
public void deactivate() {
scriptTransformations.values().forEach(this::unregisterService);
}

/**
* As {@link ScriptEngineFactory}s are added/removed, this method will cache all available script types
* and registers a transformation service for the script engine.
*/
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
public void setScriptEngineFactory(ScriptEngineFactory engineFactory) {
Optional<String> scriptType = ScriptEngineFactoryHelper.getPreferredExtension(engineFactory);
if (!scriptType.isPresent()) {
return;
}

scriptTransformations.computeIfAbsent(scriptType.get(), type -> {
ScriptTransformationService scriptTransformation = new ScriptTransformationService(type,
transformationRegistry, scriptEngineManager);

Dictionary<String, Object> properties = new Hashtable<>();
properties.put(TransformationService.SERVICE_PROPERTY_NAME, type.toUpperCase());
return bundleContext.registerService(TransformationService.class, scriptTransformation, properties);
});
}

public void unsetScriptEngineFactory(ScriptEngineFactory engineFactory) {
Optional<String> scriptType = ScriptEngineFactoryHelper.getPreferredExtension(engineFactory);
if (scriptType.isPresent()) {
Optional.ofNullable(scriptTransformations.remove(scriptType.get())).ifPresent(this::unregisterService);
}
}

public Set<String> getScriptTypes() {
return scriptTransformations.keySet();
}

@Nullable
public ScriptTransformationService getTransformationService(final String scriptType) {
ServiceRegistration reg = scriptTransformations.get(scriptType);
if (reg != null) {
return (ScriptTransformationService) bundleContext.getService(reg.getReference());
}
return null;
}

private void unregisterService(ServiceRegistration reg) {
ScriptTransformationService scriptTransformation = (ScriptTransformationService) bundleContext
.getService(reg.getReference());
scriptTransformation.deactivate();
reg.unregister();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
package org.openhab.core.automation.module.script.internal;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import javax.script.ScriptEngine;

Expand Down Expand Up @@ -67,4 +69,10 @@ public static String getLanguageName(javax.script.ScriptEngineFactory factory) {
factory.getLanguageName().substring(0, 1).toUpperCase() + factory.getLanguageName().substring(1),
factory.getLanguageVersion());
}

public static Optional<String> getPreferredExtension(ScriptEngineFactory factory) {
// return an Optional because GenericScriptEngineFactory has no scriptTypes
return factory.getScriptTypes().stream().filter(type -> !type.contains("/"))
.min(Comparator.comparing(String::length));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
@NonNullByDefault
public class ScriptProfile implements StateProfile {

public static final String CONFIG_SCRIPT_LANGUAGE = "scriptLanguage";
public static final String CONFIG_TO_ITEM_SCRIPT = "toItemScript";
public static final String CONFIG_TO_HANDLER_SCRIPT = "toHandlerScript";

Expand All @@ -54,34 +53,27 @@ public class ScriptProfile implements StateProfile {
private final List<Class<? extends Command>> acceptedCommandTypes;
private final List<Class<? extends Command>> handlerAcceptedCommandTypes;

private final String scriptLanguage;
private final String toItemScript;
private final String toHandlerScript;
private final ProfileTypeUID profileTypeUID;

private final boolean isConfigured;

public ScriptProfile(ProfileCallback callback, ProfileContext profileContext,
public ScriptProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback, ProfileContext profileContext,
TransformationService transformationService) {
this.profileTypeUID = profileTypeUID;
this.callback = callback;
this.transformationService = transformationService;

this.acceptedCommandTypes = profileContext.getAcceptedCommandTypes();
this.acceptedDataTypes = profileContext.getAcceptedDataTypes();
this.handlerAcceptedCommandTypes = profileContext.getHandlerAcceptedCommandTypes();

this.scriptLanguage = ConfigParser.valueAsOrElse(profileContext.getConfiguration().get(CONFIG_SCRIPT_LANGUAGE),
String.class, "");
this.toItemScript = ConfigParser.valueAsOrElse(profileContext.getConfiguration().get(CONFIG_TO_ITEM_SCRIPT),
String.class, "");
this.toHandlerScript = ConfigParser
.valueAsOrElse(profileContext.getConfiguration().get(CONFIG_TO_HANDLER_SCRIPT), String.class, "");

if (scriptLanguage.isBlank()) {
logger.error("Script language is not defined. Profile will discard all states and commands.");
isConfigured = false;
return;
}

if (toItemScript.isBlank() && toHandlerScript.isBlank()) {
logger.error(
"Neither 'toItem' nor 'toHandler' script defined. Profile will discard all states and commands.");
Expand All @@ -94,7 +86,7 @@ public ScriptProfile(ProfileCallback callback, ProfileContext profileContext,

@Override
public ProfileTypeUID getProfileTypeUID() {
return ScriptProfileFactory.SCRIPT_PROFILE_UID;
return profileTypeUID;
}

@Override
Expand Down Expand Up @@ -149,7 +141,7 @@ public void onStateUpdateFromHandler(State state) {
private @Nullable String executeScript(String script, Type input) {
if (!script.isBlank()) {
try {
return transformationService.transform(scriptLanguage + ":" + script, input.toFullString());
return transformationService.transform(script, input.toFullString());
} catch (TransformationException e) {
if (e.getCause() instanceof ScriptException) {
logger.error("Failed to process script '{}': {}", script, e.getCause().getMessage());
Expand Down

0 comments on commit 1bbc292

Please sign in to comment.