diff --git a/bundles/org.openhab.core.automation.module.script/pom.xml b/bundles/org.openhab.core.automation.module.script/pom.xml
index 55e69896805..f38e2a91fc0 100644
--- a/bundles/org.openhab.core.automation.module.script/pom.xml
+++ b/bundles/org.openhab.core.automation.module.script/pom.xml
@@ -25,6 +25,12 @@
org.openhab.core.transform
${project.version}
+
+ org.openhab.core.bundles
+ org.openhab.core.test
+ ${project.version}
+ test
+
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java
index 5faad600b28..708b10a9d3a 100644
--- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java
+++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java
@@ -12,6 +12,10 @@
*/
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.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
@@ -20,6 +24,7 @@
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;
@@ -29,8 +34,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;
@@ -39,6 +48,8 @@
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;
@@ -48,10 +59,13 @@
*
* @author Jan N. Klug - Initial contribution
*/
-@Component(service = TransformationService.class, property = { "openhab.transform=SCRIPT" })
+@Component(service = { TransformationService.class, ScriptTransformationService.class,
+ ConfigOptionProvider.class }, property = { "openhab.transform=SCRIPT" })
@NonNullByDefault
-public class ScriptTransformationService implements TransformationService, RegistryChangeListener {
+public class ScriptTransformationService
+ implements TransformationService, RegistryChangeListener, ConfigOptionProvider {
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
@@ -65,6 +79,8 @@ public class ScriptTransformationService implements TransformationService, Regis
private final Map scriptCache = new ConcurrentHashMap<>();
private final TransformationRegistry transformationRegistry;
+ private final Map supportedScriptTypes = new ConcurrentHashMap<>();
+
private final ScriptEngineManager scriptEngineManager;
@Activate
@@ -160,10 +176,10 @@ public void deactivate() {
// compile the script here _after_ setting context attributes, so that the script engine
// can bind the attributes as variables during compilation. This primarily affects jruby.
- if (compiledScript == null && scriptEngineContainer.getScriptEngine() instanceof Compilable) {
+ if (compiledScript == null
+ && scriptEngineContainer.getScriptEngine()instanceof Compilable scriptEngine) {
// no compiled script available but compiling is supported
- compiledScript = ((Compilable) scriptEngineContainer.getScriptEngine())
- .compile(scriptRecord.script);
+ compiledScript = scriptEngine.compile(scriptRecord.script);
scriptRecord.compiledScript = compiledScript;
}
@@ -211,14 +227,13 @@ private void disposeScriptRecord(ScriptRecord scriptRecord) {
}
private void disposeScriptEngine(ScriptEngine scriptEngine) {
- if (scriptEngine instanceof AutoCloseable) {
+ if (scriptEngine instanceof AutoCloseable closableScriptEngine) {
// we cannot not use ScheduledExecutorService.execute here as it might execute the task in the calling
// thread (calling ScriptEngine.close in the same thread may result in a deadlock if the ScriptEngine
// tries to Thread.join)
scheduler.schedule(() -> {
- AutoCloseable closeable = (AutoCloseable) scriptEngine;
try {
- closeable.close();
+ closableScriptEngine.close();
} catch (Exception e) {
logger.error("Error while closing script engine", e);
}
@@ -228,6 +243,38 @@ private void disposeScriptEngine(ScriptEngine scriptEngine) {
}
}
+ @Override
+ public @Nullable Collection 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 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;
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryHelper.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryHelper.java
new file mode 100644
index 00000000000..12ffaeb4c93
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryHelper.java
@@ -0,0 +1,70 @@
+/**
+ * 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.internal;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.script.ScriptEngine;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.module.script.ScriptEngineFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ScriptEngineFactoryHelper} contains helper methods for handling script engines
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class ScriptEngineFactoryHelper {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ScriptEngineFactoryHelper.class);
+
+ private ScriptEngineFactoryHelper() {
+ // prevent instantiation of static utility class
+ }
+
+ public static Map.@Nullable Entry getParameterOption(ScriptEngineFactory engineFactory) {
+ List scriptTypes = engineFactory.getScriptTypes();
+ if (!scriptTypes.isEmpty()) {
+ ScriptEngine scriptEngine = engineFactory.createScriptEngine(scriptTypes.get(0));
+ if (scriptEngine != null) {
+ Map.Entry parameterOption = Map.entry(getPreferredMimeType(engineFactory),
+ getLanguageName(scriptEngine.getFactory()));
+ LOGGER.trace("ParameterOptions: {}", parameterOption);
+ return parameterOption;
+ } else {
+ LOGGER.trace("setScriptEngineFactory: engine was null");
+ }
+ } else {
+ LOGGER.trace("addScriptEngineFactory: scriptTypes was empty");
+ }
+
+ return null;
+ }
+
+ public static String getPreferredMimeType(ScriptEngineFactory factory) {
+ List mimeTypes = new ArrayList<>(factory.getScriptTypes());
+ mimeTypes.removeIf(mimeType -> !mimeType.contains("application") || "application/python".equals(mimeType));
+ return mimeTypes.isEmpty() ? factory.getScriptTypes().get(0) : mimeTypes.get(0);
+ }
+
+ public static String getLanguageName(javax.script.ScriptEngineFactory factory) {
+ return String.format("%s (%s)",
+ factory.getLanguageName().substring(0, 1).toUpperCase() + factory.getLanguageName().substring(1),
+ factory.getLanguageVersion());
+ }
+}
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/provider/ScriptModuleTypeProvider.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/provider/ScriptModuleTypeProvider.java
index 4b5f4b81f64..233d0c94191 100644
--- a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/provider/ScriptModuleTypeProvider.java
+++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/provider/ScriptModuleTypeProvider.java
@@ -25,6 +25,7 @@
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.automation.Visibility;
import org.openhab.core.automation.module.script.ScriptEngineFactory;
+import org.openhab.core.automation.module.script.internal.ScriptEngineFactoryHelper;
import org.openhab.core.automation.module.script.internal.handler.AbstractScriptModuleHandler;
import org.openhab.core.automation.module.script.internal.handler.ScriptActionHandler;
import org.openhab.core.automation.module.script.internal.handler.ScriptConditionHandler;
@@ -146,21 +147,14 @@ private void notifyModuleTypesRemoved() {
*/
@Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC)
public void setScriptEngineFactory(ScriptEngineFactory engineFactory) {
- List scriptTypes = engineFactory.getScriptTypes();
- if (!scriptTypes.isEmpty()) {
- ScriptEngine scriptEngine = engineFactory.createScriptEngine(scriptTypes.get(0));
- if (scriptEngine != null) {
- boolean notifyListeners = parameterOptions.isEmpty();
- parameterOptions.put(getPreferredMimeType(engineFactory), getLanguageName(scriptEngine.getFactory()));
- logger.trace("ParameterOptions: {}", parameterOptions);
- if (notifyListeners) {
- notifyModuleTypesAdded();
- }
- } else {
- logger.trace("setScriptEngineFactory: engine was null");
+ Map.Entry parameterOption = ScriptEngineFactoryHelper.getParameterOption(engineFactory);
+ if (parameterOption != null) {
+ boolean notifyListeners = parameterOptions.isEmpty();
+ parameterOptions.put(parameterOption.getKey(), parameterOption.getValue());
+ logger.trace("ParameterOptions: {}", parameterOptions);
+ if (notifyListeners) {
+ notifyModuleTypesAdded();
}
- } else {
- logger.trace("addScriptEngineFactory: scriptTypes was empty");
}
}
@@ -169,7 +163,7 @@ public void unsetScriptEngineFactory(ScriptEngineFactory engineFactory) {
if (!scriptTypes.isEmpty()) {
ScriptEngine scriptEngine = engineFactory.createScriptEngine(scriptTypes.get(0));
if (scriptEngine != null) {
- parameterOptions.remove(getPreferredMimeType(engineFactory));
+ parameterOptions.remove(ScriptEngineFactoryHelper.getPreferredMimeType(engineFactory));
logger.trace("ParameterOptions: {}", parameterOptions);
if (parameterOptions.isEmpty()) {
notifyModuleTypesRemoved();
@@ -181,16 +175,4 @@ public void unsetScriptEngineFactory(ScriptEngineFactory engineFactory) {
logger.trace("unsetScriptEngineFactory: scriptTypes was empty");
}
}
-
- private String getPreferredMimeType(ScriptEngineFactory factory) {
- List mimeTypes = new ArrayList<>(factory.getScriptTypes());
- mimeTypes.removeIf(mimeType -> !mimeType.contains("application") || "application/python".equals(mimeType));
- return mimeTypes.isEmpty() ? factory.getScriptTypes().get(0) : mimeTypes.get(0);
- }
-
- private String getLanguageName(javax.script.ScriptEngineFactory factory) {
- return String.format("%s (%s)",
- factory.getLanguageName().substring(0, 1).toUpperCase() + factory.getLanguageName().substring(1),
- factory.getLanguageVersion());
- }
}
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfile.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfile.java
new file mode 100644
index 00000000000..68d405b021e
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfile.java
@@ -0,0 +1,164 @@
+/**
+ * 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.profile;
+
+import java.util.List;
+
+import javax.script.ScriptException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.config.core.ConfigParser;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.ProfileTypeUID;
+import org.openhab.core.thing.profiles.StateProfile;
+import org.openhab.core.transform.TransformationException;
+import org.openhab.core.transform.TransformationService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.Type;
+import org.openhab.core.types.TypeParser;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ScriptProfile} is generic profile for managing values with scripts
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@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";
+
+ private final Logger logger = LoggerFactory.getLogger(ScriptProfile.class);
+
+ private final ProfileCallback callback;
+ private final TransformationService transformationService;
+
+ private final List> acceptedDataTypes;
+ private final List> acceptedCommandTypes;
+ private final List> handlerAcceptedCommandTypes;
+
+ private final String scriptLanguage;
+ private final String toItemScript;
+ private final String toHandlerScript;
+
+ private final boolean isConfigured;
+
+ public ScriptProfile(ProfileCallback callback, ProfileContext profileContext,
+ TransformationService transformationService) {
+ 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.");
+ isConfigured = false;
+ return;
+ }
+
+ isConfigured = true;
+ }
+
+ @Override
+ public ProfileTypeUID getProfileTypeUID() {
+ return ScriptProfileFactory.SCRIPT_PROFILE_UID;
+ }
+
+ @Override
+ public void onStateUpdateFromItem(State state) {
+ }
+
+ @Override
+ public void onCommandFromItem(Command command) {
+ if (isConfigured) {
+ String returnValue = executeScript(toHandlerScript, command);
+ if (returnValue != null) {
+ // try to parse the value
+ Command newCommand = TypeParser.parseCommand(handlerAcceptedCommandTypes, returnValue);
+ if (newCommand != null) {
+ callback.handleCommand(newCommand);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onCommandFromHandler(Command command) {
+ if (isConfigured) {
+ String returnValue = executeScript(toItemScript, command);
+ if (returnValue != null) {
+ Command newCommand = TypeParser.parseCommand(acceptedCommandTypes, returnValue);
+ if (newCommand != null) {
+ callback.sendCommand(newCommand);
+ }
+ }
+ }
+ }
+
+ @Override
+ public void onStateUpdateFromHandler(State state) {
+ if (isConfigured) {
+ String returnValue = executeScript(toItemScript, state);
+ // special handling for UnDefType, it's not available in the TypeParser
+ if ("UNDEF".equals(returnValue)) {
+ callback.sendUpdate(UnDefType.UNDEF);
+ } else if ("NULL".equals(returnValue)) {
+ callback.sendUpdate(UnDefType.NULL);
+ } else if (returnValue != null) {
+ State newState = TypeParser.parseState(acceptedDataTypes, returnValue);
+ if (newState != null) {
+ callback.sendUpdate(newState);
+ }
+ }
+ }
+ }
+
+ private @Nullable String executeScript(String script, Type input) {
+ if (!script.isBlank()) {
+ try {
+ return transformationService.transform(scriptLanguage + ":" + script, input.toFullString());
+ } catch (TransformationException e) {
+ if (e.getCause() instanceof ScriptException) {
+ logger.error("Failed to process script '{}': {}", script, e.getCause().getMessage());
+ } else {
+ logger.error("Failed to process script '{}': {}", script, e.getMessage());
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfileFactory.java b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfileFactory.java
new file mode 100644
index 00000000000..adbf5c9710f
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfileFactory.java
@@ -0,0 +1,75 @@
+/**
+ * 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.profile;
+
+import java.util.Collection;
+import java.util.Locale;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.module.script.ScriptTransformationService;
+import org.openhab.core.thing.profiles.Profile;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.thing.profiles.ProfileFactory;
+import org.openhab.core.thing.profiles.ProfileType;
+import org.openhab.core.thing.profiles.ProfileTypeBuilder;
+import org.openhab.core.thing.profiles.ProfileTypeProvider;
+import org.openhab.core.thing.profiles.ProfileTypeUID;
+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.Reference;
+
+/**
+ * The {@link ScriptProfileFactory} creates {@link ScriptProfile} instances
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@Component(service = { ScriptProfileFactory.class, ProfileFactory.class, ProfileTypeProvider.class })
+@NonNullByDefault
+public class ScriptProfileFactory implements ProfileFactory, ProfileTypeProvider {
+
+ public static final ProfileTypeUID SCRIPT_PROFILE_UID = new ProfileTypeUID(
+ TransformationService.TRANSFORM_PROFILE_SCOPE, "SCRIPT");
+
+ private static final ProfileType PROFILE_TYPE_SCRIPT = ProfileTypeBuilder.newState(SCRIPT_PROFILE_UID, "Script")
+ .build();
+
+ private final ScriptTransformationService transformationService;
+
+ @Activate
+ public ScriptProfileFactory(final @Reference ScriptTransformationService transformationService) {
+ this.transformationService = transformationService;
+ }
+
+ @Override
+ public @Nullable Profile createProfile(ProfileTypeUID profileTypeUID, ProfileCallback callback,
+ ProfileContext profileContext) {
+ if (SCRIPT_PROFILE_UID.equals(profileTypeUID)) {
+ return new ScriptProfile(callback, profileContext, transformationService);
+ }
+ return null;
+ }
+
+ @Override
+ public Collection getSupportedProfileTypeUIDs() {
+ return Set.of(SCRIPT_PROFILE_UID);
+ }
+
+ @Override
+ public Collection getProfileTypes(@Nullable Locale locale) {
+ return Set.of(PROFILE_TYPE_SCRIPT);
+ }
+}
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/config/script-profile.xml b/bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/config/script-profile.xml
new file mode 100644
index 00000000000..3fd037165e6
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/config/script-profile.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+ MIME-type ("application/vnd.openhab.dsl.rule") of the scripting language
+
+
+
+ The Script for transforming states and commands from handler to item.
+
+
+
+ The Script for transforming states and commands from item to handler.
+
+
+
+
diff --git a/bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/i18n/scriptprofile.properties b/bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/i18n/scriptprofile.properties
new file mode 100644
index 00000000000..021f5331014
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/i18n/scriptprofile.properties
@@ -0,0 +1,6 @@
+profile.system.script.scriptLanguage.label = Script Language
+profile.system.script.scriptLanguage.description = MIME-type ("application/vnd.openhab.dsl.rule") of the scripting language
+profile.system.script.toItemScript.label = To Item Script
+profile.system.script.toItemScript.description = The Script for transforming states and commands from handler to item.
+profile.system.script.toHandlerScript.label = To Handler Script
+profile.system.script.toHandlerScript.description = The Script for transforming states and commands from item to handler.
diff --git a/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/profile/ScriptProfileTest.java b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/profile/ScriptProfileTest.java
new file mode 100644
index 00000000000..8b26a0c0ed2
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/profile/ScriptProfileTest.java
@@ -0,0 +1,307 @@
+/**
+ * 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.profile;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.openhab.core.automation.module.script.profile.ScriptProfile.CONFIG_SCRIPT_LANGUAGE;
+import static org.openhab.core.automation.module.script.profile.ScriptProfile.CONFIG_TO_HANDLER_SCRIPT;
+import static org.openhab.core.automation.module.script.profile.ScriptProfile.CONFIG_TO_ITEM_SCRIPT;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ScheduledExecutorService;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.test.java.JavaTest;
+import org.openhab.core.thing.profiles.ProfileCallback;
+import org.openhab.core.thing.profiles.ProfileContext;
+import org.openhab.core.transform.TransformationException;
+import org.openhab.core.transform.TransformationService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+
+/**
+ * The {@link ScriptProfileTest} contains tests for the {@link ScriptProfile}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+public class ScriptProfileTest extends JavaTest {
+ private @Mock @NonNullByDefault({}) ProfileCallback profileCallback;
+
+ private @Mock @NonNullByDefault({}) TransformationService transformationServiceMock;
+
+ @BeforeEach
+ public void setUp() throws TransformationException {
+ when(transformationServiceMock.transform(any(), any())).thenReturn("");
+ }
+
+ @Test
+ public void testScriptNotExecutedAndNoValueForwardedToCallbackIfNoScriptDefined() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL").build();
+
+ setupInterceptedLogger(ScriptProfile.class, LogLevel.ERROR);
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(OnOffType.ON);
+ scriptProfile.onStateUpdateFromHandler(OnOffType.ON);
+ scriptProfile.onCommandFromItem(OnOffType.ON);
+
+ verify(transformationServiceMock, never()).transform(any(), any());
+ verify(profileCallback, never()).handleCommand(any());
+ verify(profileCallback, never()).sendUpdate(any());
+ verify(profileCallback, never()).sendCommand(any());
+
+ assertLogMessage(ScriptProfile.class, LogLevel.ERROR,
+ "Neither 'toItem' nor 'toHandler' script defined. Profile will discard all states and commands.");
+ }
+
+ @Test
+ public void testScriptNotExecutedAndNoValueForwardedToCallbackIfNoScriptLanguageDefined()
+ throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withToItemScript("inScript")
+ .withToHandlerScript("outScript").withAcceptedCommandTypes(List.of(DecimalType.class))
+ .withAcceptedDataTypes(List.of(PercentType.class))
+ .withHandlerAcceptedCommandTypes(List.of(HSBType.class)).build();
+
+ setupInterceptedLogger(ScriptProfile.class, LogLevel.ERROR);
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(OnOffType.ON);
+ scriptProfile.onStateUpdateFromHandler(OnOffType.ON);
+ scriptProfile.onCommandFromItem(OnOffType.ON);
+
+ verify(transformationServiceMock, never()).transform(any(), any());
+ verify(profileCallback, never()).handleCommand(any());
+ verify(profileCallback, never()).sendUpdate(any());
+ verify(profileCallback, never()).sendCommand(any());
+
+ assertLogMessage(ScriptProfile.class, LogLevel.ERROR,
+ "Script language is not defined. Profile will discard all states and commands.");
+ }
+
+ @Test
+ public void scriptExecutionErrorForwardsNoValueToCallback() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL")
+ .withToItemScript("inScript").withToHandlerScript("outScript").build();
+
+ when(transformationServiceMock.transform(any(), any()))
+ .thenThrow(new TransformationException("intentional failure"));
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(OnOffType.ON);
+ scriptProfile.onStateUpdateFromHandler(OnOffType.ON);
+ scriptProfile.onCommandFromItem(OnOffType.ON);
+
+ verify(transformationServiceMock, times(3)).transform(any(), any());
+ verify(profileCallback, never()).handleCommand(any());
+ verify(profileCallback, never()).sendUpdate(any());
+ verify(profileCallback, never()).sendCommand(any());
+ }
+
+ @Test
+ public void scriptExecutionResultNullForwardsNoValueToCallback() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL")
+ .withToItemScript("inScript").withToHandlerScript("outScript").build();
+
+ when(transformationServiceMock.transform(any(), any())).thenReturn(null);
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(OnOffType.ON);
+ scriptProfile.onStateUpdateFromHandler(OnOffType.ON);
+ scriptProfile.onCommandFromItem(OnOffType.ON);
+
+ verify(transformationServiceMock, times(3)).transform(any(), any());
+ verify(profileCallback, never()).handleCommand(any());
+ verify(profileCallback, never()).sendUpdate(any());
+ verify(profileCallback, never()).sendCommand(any());
+ }
+
+ @Test
+ public void scriptExecutionResultForwardsTransformedValueToCallback() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL")
+ .withToItemScript("inScript").withToHandlerScript("outScript")
+ .withAcceptedCommandTypes(List.of(OnOffType.class)).withAcceptedDataTypes(List.of(OnOffType.class))
+ .withHandlerAcceptedCommandTypes(List.of(OnOffType.class)).build();
+
+ when(transformationServiceMock.transform(any(), any())).thenReturn(OnOffType.OFF.toString());
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(DecimalType.ZERO);
+ scriptProfile.onStateUpdateFromHandler(DecimalType.ZERO);
+ scriptProfile.onCommandFromItem(DecimalType.ZERO);
+
+ verify(transformationServiceMock, times(3)).transform(any(), any());
+ verify(profileCallback).handleCommand(OnOffType.OFF);
+ verify(profileCallback).sendUpdate(OnOffType.OFF);
+ verify(profileCallback).sendCommand(OnOffType.OFF);
+ }
+
+ @Test
+ public void onlyToItemScriptDoesNotForwardOutboundCommands() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL")
+ .withToItemScript("inScript").withAcceptedCommandTypes(List.of(OnOffType.class))
+ .withAcceptedDataTypes(List.of(OnOffType.class))
+ .withHandlerAcceptedCommandTypes(List.of(DecimalType.class)).build();
+
+ when(transformationServiceMock.transform(any(), any())).thenReturn(OnOffType.OFF.toString());
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(DecimalType.ZERO);
+ scriptProfile.onStateUpdateFromHandler(DecimalType.ZERO);
+ scriptProfile.onCommandFromItem(DecimalType.ZERO);
+
+ verify(transformationServiceMock, times(2)).transform(any(), any());
+ verify(profileCallback, never()).handleCommand(any());
+ verify(profileCallback).sendUpdate(OnOffType.OFF);
+ verify(profileCallback).sendCommand(OnOffType.OFF);
+ }
+
+ @Test
+ public void onlyToHandlerScriptDoesNotForwardInboundCommands() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL")
+ .withToHandlerScript("outScript").withAcceptedCommandTypes(List.of(DecimalType.class))
+ .withAcceptedDataTypes(List.of(DecimalType.class))
+ .withHandlerAcceptedCommandTypes(List.of(OnOffType.class)).build();
+
+ when(transformationServiceMock.transform(any(), any())).thenReturn(OnOffType.OFF.toString());
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(DecimalType.ZERO);
+ scriptProfile.onStateUpdateFromHandler(DecimalType.ZERO);
+ scriptProfile.onCommandFromItem(DecimalType.ZERO);
+
+ verify(transformationServiceMock).transform(any(), any());
+ verify(profileCallback).handleCommand(OnOffType.OFF);
+ verify(profileCallback, never()).sendUpdate(any());
+ verify(profileCallback, never()).sendCommand(any());
+ }
+
+ @Test
+ public void incompatibleStateOrCommandNotForwardedToCallback() throws TransformationException {
+ ProfileContext profileContext = ProfileContextBuilder.create().withScriptLanguage("customDSL")
+ .withToItemScript("inScript").withToHandlerScript("outScript")
+ .withAcceptedCommandTypes(List.of(DecimalType.class)).withAcceptedDataTypes(List.of(PercentType.class))
+ .withHandlerAcceptedCommandTypes(List.of(HSBType.class)).build();
+
+ when(transformationServiceMock.transform(any(), any())).thenReturn(OnOffType.OFF.toString());
+
+ ScriptProfile scriptProfile = new ScriptProfile(profileCallback, profileContext, transformationServiceMock);
+
+ scriptProfile.onCommandFromHandler(DecimalType.ZERO);
+ scriptProfile.onStateUpdateFromHandler(DecimalType.ZERO);
+ scriptProfile.onCommandFromItem(DecimalType.ZERO);
+
+ verify(transformationServiceMock, times(3)).transform(any(), any());
+ verify(profileCallback, never()).handleCommand(any());
+ verify(profileCallback, never()).sendUpdate(any());
+ verify(profileCallback, never()).sendCommand(any());
+ }
+
+ private static class ProfileContextBuilder {
+ private final Map configuration = new HashMap<>();
+ private List> acceptedDataTypes = List.of();
+ private List> acceptedCommandTypes = List.of();
+ private List> handlerAcceptedCommandTypes = List.of();
+
+ public static ProfileContextBuilder create() {
+ return new ProfileContextBuilder();
+ }
+
+ public ProfileContextBuilder withScriptLanguage(String scriptLanguage) {
+ configuration.put(CONFIG_SCRIPT_LANGUAGE, scriptLanguage);
+ return this;
+ }
+
+ public ProfileContextBuilder withToItemScript(String toItem) {
+ configuration.put(CONFIG_TO_ITEM_SCRIPT, toItem);
+ return this;
+ }
+
+ public ProfileContextBuilder withToHandlerScript(String toHandlerScript) {
+ configuration.put(CONFIG_TO_HANDLER_SCRIPT, toHandlerScript);
+ return this;
+ }
+
+ public ProfileContextBuilder withAcceptedDataTypes(List> acceptedDataTypes) {
+ this.acceptedDataTypes = acceptedDataTypes;
+ return this;
+ }
+
+ public ProfileContextBuilder withAcceptedCommandTypes(List> acceptedCommandTypes) {
+ this.acceptedCommandTypes = acceptedCommandTypes;
+ return this;
+ }
+
+ public ProfileContextBuilder withHandlerAcceptedCommandTypes(
+ List> handlerAcceptedCommandTypes) {
+ this.handlerAcceptedCommandTypes = handlerAcceptedCommandTypes;
+ return this;
+ }
+
+ public ProfileContext build() {
+ return new ProfileContext() {
+ @Override
+ public Configuration getConfiguration() {
+ return new Configuration(configuration);
+ }
+
+ @Override
+ public ScheduledExecutorService getExecutorService() {
+ throw new IllegalStateException();
+ }
+
+ @Override
+ public List> getAcceptedDataTypes() {
+ return acceptedDataTypes;
+ }
+
+ @Override
+ public List> getAcceptedCommandTypes() {
+ return acceptedCommandTypes;
+ }
+
+ @Override
+ public List> getHandlerAcceptedCommandTypes() {
+ return handlerAcceptedCommandTypes;
+ }
+ };
+ }
+ }
+}