From 7bad9baa85c54abb63e09def00a73d74fb71553b Mon Sep 17 00:00:00 2001 From: J-N-K Date: Mon, 20 Mar 2023 21:59:48 +0100 Subject: [PATCH] Introduce a profile for the generic SCRIPT transformation (#3292) * Introduce a generic script profile Signed-off-by: Jan N. Klug --- .../pom.xml | 6 + .../script/ScriptTransformationService.java | 63 +++- .../internal/ScriptEngineFactoryHelper.java | 70 ++++ .../provider/ScriptModuleTypeProvider.java | 36 +- .../module/script/profile/ScriptProfile.java | 164 ++++++++++ .../script/profile/ScriptProfileFactory.java | 75 +++++ .../OH-INF/config/script-profile.xml | 22 ++ .../OH-INF/i18n/scriptprofile.properties | 6 + .../script/profile/ScriptProfileTest.java | 307 ++++++++++++++++++ 9 files changed, 714 insertions(+), 35 deletions(-) create mode 100644 bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/internal/ScriptEngineFactoryHelper.java create mode 100644 bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfile.java create mode 100644 bundles/org.openhab.core.automation.module.script/src/main/java/org/openhab/core/automation/module/script/profile/ScriptProfileFactory.java create mode 100644 bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/config/script-profile.xml create mode 100644 bundles/org.openhab.core.automation.module.script/src/main/resources/OH-INF/i18n/scriptprofile.properties create mode 100644 bundles/org.openhab.core.automation.module.script/src/test/java/org/openhab/core/automation/module/script/profile/ScriptProfileTest.java 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; + } + }; + } + } +}