diff --git a/bundles/org.openhab.core.automation.module.script/pom.xml b/bundles/org.openhab.core.automation.module.script/pom.xml
index 1bdd6b83181..98314fe0421 100644
--- a/bundles/org.openhab.core.automation.module.script/pom.xml
+++ b/bundles/org.openhab.core.automation.module.script/pom.xml
@@ -20,6 +20,11 @@
org.openhab.core.automation
${project.version}
+
+ org.openhab.core.bundles
+ org.openhab.core.transform
+ ${project.version}
+
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
new file mode 100644
index 00000000000..9c575b3b9a3
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/ScriptTransformationService.java
@@ -0,0 +1,147 @@
+/**
+ * Copyright (c) 2010-2022 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.HashMap;
+import java.util.Map;
+
+import javax.script.Compilable;
+import javax.script.CompiledScript;
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.ScriptException;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.registry.RegistryChangeListener;
+import org.openhab.core.transform.TransformationConfiguration;
+import org.openhab.core.transform.TransformationConfigurationRegistry;
+import org.openhab.core.transform.TransformationException;
+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 ScriptTransformationService} implements a {@link TransformationService} using any available script
+ * language
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@Component(service = TransformationService.class, property = { "openhab.transform=SCRIPT" })
+@NonNullByDefault
+public class ScriptTransformationService
+ implements TransformationService, RegistryChangeListener {
+ public static final String OPENHAB_TRANSFORMATION_SCRIPT = "openhab-transformation-script-";
+
+ private final Map scriptEngineContainers = new HashMap<>();
+ private final Map compiledScripts = new HashMap<>();
+ private final Map scriptCache = new HashMap<>();
+
+ private final TransformationConfigurationRegistry transformationConfigurationRegistry;
+ private final ScriptEngineManager scriptEngineManager;
+
+ @Activate
+ public ScriptTransformationService(
+ @Reference TransformationConfigurationRegistry transformationConfigurationRegistry,
+ @Reference ScriptEngineManager scriptEngineManager) {
+ this.transformationConfigurationRegistry = transformationConfigurationRegistry;
+ this.scriptEngineManager = scriptEngineManager;
+ }
+
+ @Override
+ public @Nullable String transform(String function, String source) throws TransformationException {
+ int splitPoint = function.indexOf(":");
+ if (splitPoint < 1) {
+ throw new TransformationException("Script Type must be prepended to transformation UID.");
+ }
+ String scriptType = function.substring(0, splitPoint);
+ String scriptUid = function.substring(splitPoint + 1);
+
+ String script = scriptCache.get(scriptUid);
+ if (script == null) {
+ TransformationConfiguration transformationConfiguration = transformationConfigurationRegistry
+ .get(scriptUid);
+ if (transformationConfiguration != null) {
+ script = transformationConfiguration.getContent();
+ }
+ if (script == null) {
+ throw new TransformationException("Could not get script for UID '" + scriptUid + "'.");
+ }
+
+ scriptCache.put(scriptUid, script);
+ }
+
+ if (!scriptEngineManager.isSupported(scriptType)) {
+ // language has been removed, clear container and compiled scripts if found
+ if (scriptEngineContainers.containsKey(scriptUid)) {
+ scriptEngineManager.removeEngine(OPENHAB_TRANSFORMATION_SCRIPT + scriptUid);
+ }
+ clearCache(scriptUid);
+ throw new TransformationException(
+ "Script type '" + scriptType + "' is not supported by any available script engine.");
+ }
+
+ ScriptEngineContainer scriptEngineContainer = scriptEngineContainers.computeIfAbsent(scriptUid,
+ k -> scriptEngineManager.createScriptEngine(scriptType, OPENHAB_TRANSFORMATION_SCRIPT + k));
+
+ if (scriptEngineContainer == null) {
+ throw new TransformationException("Failed to create script engine container for '" + function + "'.");
+ }
+ if (scriptEngineContainer != null) {
+ try {
+ CompiledScript compiledScript = this.compiledScripts.get(scriptUid);
+
+ if (compiledScript == null && scriptEngineContainer.getScriptEngine() instanceof Compilable) {
+ // no compiled script available but compiling is supported
+ compiledScript = ((Compilable) scriptEngineContainer.getScriptEngine()).compile(script);
+ this.compiledScripts.put(scriptUid, compiledScript);
+ }
+
+ ScriptEngine engine = compiledScript != null ? compiledScript.getEngine()
+ : scriptEngineContainer.getScriptEngine();
+ ScriptContext executionContext = engine.getContext();
+ executionContext.setAttribute("inputString", source, ScriptContext.ENGINE_SCOPE);
+
+ Object result = compiledScript != null ? compiledScript.eval() : engine.eval(script);
+ return result == null ? null : result.toString();
+ } catch (ScriptException e) {
+ throw new TransformationException("Failed to execute script.", e);
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ public void added(TransformationConfiguration element) {
+ clearCache(element.getUID());
+ }
+
+ @Override
+ public void removed(TransformationConfiguration element) {
+
+ clearCache(element.getUID());
+ }
+
+ @Override
+ public void updated(TransformationConfiguration oldElement, TransformationConfiguration element) {
+ clearCache(element.getUID());
+ }
+
+ private void clearCache(String uid) {
+ compiledScripts.remove(uid);
+ scriptEngineContainers.remove(uid);
+ scriptCache.remove(uid);
+ }
+}
diff --git a/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java
new file mode 100644
index 00000000000..865655e0407
--- /dev/null
+++ b/bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/ScriptTransformationServiceTest.java
@@ -0,0 +1,141 @@
+/**
+ * Copyright (c) 2010-2022 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 static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import javax.script.ScriptContext;
+import javax.script.ScriptEngine;
+import javax.script.ScriptException;
+
+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.transform.TransformationConfiguration;
+import org.openhab.core.transform.TransformationConfigurationRegistry;
+import org.openhab.core.transform.TransformationException;
+
+/**
+ * The {@link ScriptTransformationServiceTest} holds tests for the {@link ScriptTransformationService}
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+public class ScriptTransformationServiceTest {
+ private static final String COMPILABLE_SCRIPT_TYPE = "compilableScript";
+ private static final String SCRIPT_TYPE = "script";
+ private static final String SCRIPT_UID = "scriptUid";
+ private static final String SCRIPT = "script";
+ private static final String SCRIPT_OUTPUT = "output";
+
+ private static final TransformationConfiguration TRANSFORMATION_CONFIGURATION = new TransformationConfiguration(
+ SCRIPT_UID, "label", "script", null, SCRIPT);
+ private @Mock @NonNullByDefault({}) TransformationConfigurationRegistry transformationConfigurationRegistry;
+ private @Mock @NonNullByDefault({}) ScriptEngineManager scriptEngineManager;
+ private @Mock @NonNullByDefault({}) ScriptEngineContainer scriptEngineContainer;
+ private @Mock @NonNullByDefault({}) ScriptEngine scriptEngine;
+ private @Mock @NonNullByDefault({}) ScriptContext scriptContext;
+
+ private @NonNullByDefault({}) ScriptTransformationService service;
+
+ @BeforeEach
+ public void setUp() throws ScriptException {
+ service = new ScriptTransformationService(transformationConfigurationRegistry, scriptEngineManager);
+
+ when(scriptEngineManager.createScriptEngine(eq(SCRIPT_TYPE), any())).thenReturn(scriptEngineContainer);
+ when(scriptEngineManager.isSupported(anyString()))
+ .thenAnswer(scriptType -> SCRIPT_TYPE.equals(scriptType.getArgument(0)));
+ when(scriptEngineContainer.getScriptEngine()).thenReturn(scriptEngine);
+ when(scriptEngine.eval(SCRIPT)).thenReturn("output");
+ when(scriptEngine.getContext()).thenReturn(scriptContext);
+
+ when(transformationConfigurationRegistry.get(anyString())).thenAnswer(
+ scriptUid -> SCRIPT_UID.equals(scriptUid.getArgument(0)) ? TRANSFORMATION_CONFIGURATION : null);
+ }
+
+ @Test
+ public void success() throws TransformationException {
+ String returnValue = service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input");
+
+ assertThat(returnValue, is(SCRIPT_OUTPUT));
+ }
+
+ @Test
+ public void scriptsAreCached() throws TransformationException {
+ service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input");
+ service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input");
+
+ verify(transformationConfigurationRegistry).get(SCRIPT_UID);
+ }
+
+ @Test
+ public void scriptCacheInvalidatedAfterChange() throws TransformationException {
+ service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input");
+ service.updated(TRANSFORMATION_CONFIGURATION, TRANSFORMATION_CONFIGURATION);
+ service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input");
+
+ verify(transformationConfigurationRegistry, times(2)).get(SCRIPT_UID);
+ }
+
+ @Test
+ public void noScriptTypeThrowsException() {
+ TransformationException e = assertThrows(TransformationException.class,
+ () -> service.transform(SCRIPT_UID, "input"));
+
+ assertThat(e.getMessage(), is("Script Type must be prepended to transformation UID."));
+ }
+
+ @Test
+ public void unknownScriptTypeThrowsException() {
+ TransformationException e = assertThrows(TransformationException.class,
+ () -> service.transform("foo" + ":" + SCRIPT_UID, "input"));
+
+ assertThat(e.getMessage(), is("Script type 'foo' is not supported by any available script engine."));
+ }
+
+ @Test
+ public void unknownScriptUidThrowsException() {
+ TransformationException e = assertThrows(TransformationException.class,
+ () -> service.transform(SCRIPT_TYPE + ":" + "foo", "input"));
+
+ assertThat(e.getMessage(), is("Could not get script for UID 'foo'."));
+ }
+
+ @Test
+ public void scriptExceptionResultsInTransformationException() throws ScriptException {
+ when(scriptEngine.eval(SCRIPT)).thenThrow(new ScriptException("exception"));
+
+ TransformationException e = assertThrows(TransformationException.class,
+ () -> service.transform(SCRIPT_TYPE + ":" + SCRIPT_UID, "input"));
+
+ assertThat(e.getMessage(), is("Failed to execute script."));
+ assertThat(e.getCause(), instanceOf(ScriptException.class));
+ assertThat(e.getCause().getMessage(), is("exception"));
+ }
+}