diff --git a/CODEOWNERS b/CODEOWNERS
index 50a78212abfdc..a3f5c4537cef2 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -379,6 +379,7 @@
/bundles/org.openhab.voice.googletts/ @gbicskei
/bundles/org.openhab.voice.mactts/ @kaikreuzer
/bundles/org.openhab.voice.marytts/ @kaikreuzer
+/bundles/org.openhab.voice.actiontemplatehli/ @GiviMAD
/bundles/org.openhab.voice.picotts/ @FlorianSW
/bundles/org.openhab.voice.pollytts/ @hillmanr
/bundles/org.openhab.voice.porcupineks/ @GiviMAD
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 930b8d50a74cb..d5c6434097b3f 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1886,6 +1886,11 @@
org.openhab.voice.marytts${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.voice.actiontemplatehli
+ ${project.version}
+ org.openhab.addons.bundlesorg.openhab.voice.picotts
diff --git a/bundles/org.openhab.voice.actiontemplatehli/NOTICE b/bundles/org.openhab.voice.actiontemplatehli/NOTICE
new file mode 100644
index 0000000000000..5600993d81964
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/NOTICE
@@ -0,0 +1,25 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+opennlp-tools
+* License: Apache 2.0 License
+* Project: https://github.com/apache
+* Source: https://github.com/apache/opennlp
+
+jackson
+* License: Apache 2.0 License
+* Project: https://github.com/FasterXML/jackson
+* Source: https://github.com/FasterXML/jackson
diff --git a/bundles/org.openhab.voice.actiontemplatehli/README.md b/bundles/org.openhab.voice.actiontemplatehli/README.md
new file mode 100644
index 0000000000000..af4be381e335a
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/README.md
@@ -0,0 +1,390 @@
+# Action Template Interpreter
+
+A human language interpreter implementation powered by OpenNLP.
+This is an attempt to provide you with a template system to match text commands to specific items and read its state or send command to them.
+For doing this the interpreter takes advantage of some nlp techniques.
+
+The (Apache OpenNLP)[https://opennlp.apache.org] library is a machine learning based toolkit for the processing of natural language text.
+This human language interpreter aims to have no language dependency as it does nothing out of the box, please report any incompatibility with your language.
+
+You can find models provided by OpenNLP for some languages [here](https://opennlp.apache.org/models.html) and [here](http://opennlp.sourceforge.net/models-1.5/).
+Those are not required, as you can use the build-in white space or simple tokenizers (from OpenNLP),they are just required to use the match by lemmas and the optional language tag functionalities.
+
+There are some examples at the end that you can review if you want a general idea of what can be done.
+
+## NLP Terminology
+
+I will briefly explain some terms that will be used:
+
+* Tokenize: first step of the recognition is to split the text, each of these parts is called token.
+* Named Entity Recognition (NER): is the process of finding a subset of tokens on the input.
+* Part Of Speech (POS) tagging: categorizing tokens in a text, depending on the definition of the token and its context.
+* Lemmatize: is the process of getting a generic representation of the tokens, each of it is called lemma. (Example of one token to lemma conversion: 'is' -> 'be').
+
+## Action Template Target:
+
+This interpreter allows two ways to target items:
+
+* You can link an action to a specific item by adding the custom metadata namespace 'actiontemplatehli' to it.
+* You can link an action to all items of a type by providing the file '/actiontemplatehli/type_actions/.json' (here you can restrict each action by item tags).
+
+Two important notes:
+
+* Actions linked to items have prevalence over actions linked to a whole item type.
+* On actions linked to an item type the itemLabel placeholder will always be applied (explained bellow). If there are multiple item labels detected on the input the actions linked to both will be scored, each using the correct itemLabel value, so there should be no collisions in case a template contains a token that matches an item label.
+
+## Action Template Scoring
+
+The scoring mechanism scores each action configuration to look for the best scored.
+If a token fails the comparison, the action scores 0.
+If all actions scored 0, none is executed.
+
+The action configuration field 'type' defines if the template should be compared using tokens or lemmas.
+Please note that the captured placeholder value is extracted from the tokens not from the lemmas, but the equivalent lemmas are replaced before scoring.
+
+The action template string is a list of tokens separated by white space.
+You can use the ';' separator to provide alternative templates and the '|' to provide alternative tokens.
+Here is an example of string: "what app|application is open on $itemLabel;what app|application is on $itemLabel".
+
+Take in account that, as this is a token basis comparison, matching depends on the tokenizer you are using as they can produce different tokens for the same text.
+
+## Action Template Options:
+
+The location where action configurations are placed changes whether you are targeting an item or many, so take a look to the 'Action Template Target' to understand where to put those configurations.
+Also, you can check the paths indicated on the examples at the end.
+
+Actions can read the state from an item or send a command to it.
+This is defined by the boolean field read which is 'false' by default.
+
+When read is false:
+
+* template: action template. (Required)
+* value: value to be sent. It can be used to capture the transformed placeholder values. (Required unless the target item type is String, in which case silent mode is assumed to be true and the whole text is passed to the item).
+* type: action template type, either "tokens" or "lemmas".
+* requiredTags: allow to restrict the items targeted by its tags by ignoring items not having all these tags.
+* placeholders: defined placeholders that can be used on the template and replaced on the value.
+* silent: boolean used to avoid confirmation message.
+* targetMembers: when targeting a Group item, can be used to send the command to its member items instead.
+
+When read is true:
+
+* template: action template. (Required)
+* value: read template, can use the placeholders symbols $itemLabel and $state.
+* emptyValue: An alternative template. Is used when the state value is empty or NULL after the post transformation. The $itemLabel is available.
+* type: action template type, either "tokens" or "lemmas".
+* requiredTags: allow to restrict the items targeted by its tags by ignoring items not having all these tags.
+* placeholders: only the placeholder with label state will be used, to process its POS transformation on the state.
+* targetMembers: when targeting a Group item, can be used to access the state of one of its members. In case of multiple matches, a warning is shown and the first one is used.
+
+## Placeholders
+
+This configuration allow you to define symbols to use on your templates.
+You can define the sets of tokens to match using ner, and a transformation using pos.
+Those are its fields:
+
+ * label: label for the placeholder, is prefixed with '$' and spaces are replaced by '_' to create the symbol you can use on the template (Required).
+ * nerValues: list of strings containing parts of the text to look for. Takes precedence over the ner field.
+ * ner: name for a file under the ner folder (/actiontemplatehli/ner), first it will look for a .bin model and then for a .xml dictionary for applying ner (prevalence over 'nerValues').
+ * posValues: apply a pos transformation with static values. Takes precedence over the pos field.
+ * pos: name for a file under the pos folder (/actiontemplatehli/pos), first it will look for a .bin model and then for a .xml dictionary (prevalence over 'posValues').
+
+The placeholder symbol replaces the text tokens matched using NER (before scoring the actions) and the captured value could be transformed using POS and will be accessible to the value under its symbol.
+As a summary, using the placeholders you can configure how parts of the speech are converted into valid item values and backward.
+The examples at the end of the document can help you to see it clearer.
+
+There are some special placeholders:
+
+### The 'itemLabel' Placeholder
+
+The itemLabel placeholder is always applied when scoring actions linked to item types. It's replaced using NER (no case-sensitive) with your item labels and synonyms (collisions will be reported in debug logs). Its value is only available for read actions.
+
+### The 'groupLabel' Placeholder
+
+The groupLabel placeholder is only available for read actions when targeting a group member.
+When it's present, the 'itemLabel' placeholder will take the value of the target member label and the 'groupLabel' the label of the group.
+
+### The 'state' Placeholder
+
+It's used to access the value on the read actions, you can configure a POS transformation for it.
+
+### The 'itemOption' Placeholder
+
+This placeholder is available for both write and read actions and doesn't need to be configured.
+
+When read is false, the 'itemOption' placeholder will be computed from the item command description options, or from the state description options if the command description options are not present.
+
+When read is true, the 'itemOption' placeholder will be computed from the item state description options.
+
+Note that, when targeting multiple group members, 'ner' (value matching) is done by merging all available member options but 'pos' (value transformation) is done using just the target member item options.
+
+### The '*' Placeholder (the dynamic placeholder)
+
+The dynamic placeholder is designed to capture free text. The captured value is exposed to the value under the symbol '$*' as other placeholders.
+It has some restrictions:
+
+* Can not be the only token on the template.
+* Can not be used as an optional token.
+* Can not be used multiple times on the same template alternative.
+
+Note that the dynamic placeholder does not score. This way you can use it to fallback other sentences.
+
+This example can help you to understand how this works:
+
+You have an action with the template "play $* on living room" and another action with the template "play $musicAuthor on living room" and assuming 'mozart' is a valid value for the placeholder '$musicAuthor'.
+
+The sentence "play mozart on living room" will score 4 when compared with the template containing the dynamic placeholder and 5 when compared with the one without it.
+The action with template "play $musicAuthor on living room" will be executed.
+
+The sentence "play beethoven on living room" will score 4 when compared with the template containing the dynamic placeholder and 0 when compared with the one without it.
+The action with template "play $* on living room" will be executed.
+
+### POS Transformation
+
+POS is a technique which produces tags for each token, here though we are going to use it to match a group of words with a value so we should transform those words to a single token.
+That's the reason why the whitespace character should be replaced by '__' in the POS dictionaries and static values, you can see some examples bellow.
+
+### Target members:
+
+When the target of an action is a group item, you can target its members instead.
+
+You can use the following fields:
+
+* itemName: name of the item member to target. If present the other fields are ignored.
+* itemType: type of the item members to target.
+* requiredTags: allow to restrict the members targeted by tags when matching by type.
+* recursive: when matching by itemType, look for group members in a recursive way, default true.
+* mergeState: on a read action when matching by itemType, merge the item states by performing an AND operation, only allowed for 'Switch' and 'Contact' item types, default false.
+
+## Text Preprocessing
+
+The interpreter needs to match the input text with a target item and action configuration, to know what to do.
+To do so, it needs the tokens and optionally the POS tags and the lemmas.
+
+### Tokenizer
+
+You can provide a custom model at '/actiontemplatehli/token.bin', otherwise it will use the built-in simple tokenizer or whitespace tokenizer (configurable).
+
+Here you have an example of the built-in ones:
+
+* Using the white space tokenizer "What time is it?" produces the tokens "what" "time" "is" "it?"
+* Using the simple tokenizer "What time is it?" produces the tokens "what" "time" "is" "it" "?"
+
+Tokenizing the text is enough to use the action type 'tokens' as tokens are the only ones required for scoring (but the option 'optionalLanguageTags' will not take effect unless you have the POS language tags).
+
+### POSTagger (language tags)
+
+You need to provide a model for POS tagging at '/actiontemplatehli/pos.bin' for your language.
+This will produce a language tag for each token, that can be used in 'optionalLanguageTags' to make some optional for scoring.
+Please note that these labels may be different depending on the model, please refer to your model's documentation.
+As an example:
+
+The tokens "that,sounds,good" produces the tags "DT,VBZ,JJ".
+
+Assuming optionalLanguageTags is empty, if we have an action with template "sounds good" it will get a 0 score when compared to the text "that sounds good" because the token "that" is not in the template.
+
+But if we set optionalLanguageTags to "DT", the action template "sounds good" will score 2 against the text "that sounds good" as the tokens with the tag "DT" are considered optional when scoring.
+
+Note that if we have another action with the template "that sounds good" it will score 3 and take prevalence.
+
+You need the correct language tags for the lemmatizer to work.
+
+### Lemmatizer
+
+You need to provide a model for the lemmatizer at '/actiontemplatehli/lemma.bin' for your language.
+This will produce a lemma for each token, then you can use the action type 'lemmas'.
+
+Note that you need the POS language tags for your language, the ones covered on the previous section, for the lemmatizer to work.
+
+## Interpreter Configuration
+
+| Config | Group | Type | Default | Description |
+|-------------------------|----------|---------|----------------------|---------------------------------------------------------------------------------------------------------------|
+| lowerText | nlp | boolean | false | Convert the input text to lowercase before processing |
+| caseSensitive | nlp | boolean | false | Enable case sensitivity, do not apply to dictionaries and models, do not apply to the 'itemLabel' placeholder |
+| useSimpleTokenizer | nlp | boolean | false | Prefer simple tokenizer over white space tokenizer |
+| detokenizeOptimization | nlp | boolean | true | Enables build-in detokenization based on original text, otherwise string join by space is used |
+| optionalLanguageTags | nlp | text | | Comma separated POS language tags that will be optional when comparing |
+| commandSentMessage | messages | text | Done | Message for successful command |
+| unhandledMessage | messages | text | I can not do that | Message for unsuccessful action |
+| failureMessage | messages | text | There was an error | Message for error during processing |
+
+## Examples:
+
+### String type action configs example:
+
+This example contains the files to add actions for opening an android application and checking what application is opened.
+These actions will target all String items with the tag 'launch_android_app'.
+
+These are the files needed:
+
+#### File '/actiontemplatehli/type_actions/String.json'
+
+```json
+[
+ {
+ "template": "launch|open $app on $itemLabel",
+ "value": "$app",
+ "type": "tokens",
+ "requiredTags": ["launch_android_app"],
+ "placeholders": [
+ {
+ "label": "app",
+ "ner": "applications",
+ "pos": "application_to_package"
+ }
+ ]
+ },
+ {
+ "template": "what app|application is open on $itemLabel;what app|application is on $itemLabel",
+ "read": true,
+ "value": "the open app is $state",
+ "emptyValue": "no app open on $itemLabel",
+ "type": "tokens",
+ "requiredTags": ["launch_android_app"],
+ "placeholders": [
+ {
+ "label": "state",
+ "pos": "package_to_application"
+ }
+ ]
+ }
+]
+```
+
+#### File '/actiontemplatehli/ner/applications.xml'
+
+```xml
+
+
+
+ youtube
+
+
+ jellyfin
+
+
+ amazon
+ video
+
+
+ netflix
+
+
+```
+
+#### File '/actiontemplatehli/pos/package_to_application.xml'
+
+```xml
+
+
+
+ com.google.android.youtube
+
+
+ com.netflix.ninja
+
+
+ org.jellyfin.androidtv
+
+ // note the __
+ com.amazon.amazonvideo.livingroom
+
+
+```
+
+#### File '/actiontemplatehli/pos/application_to_package.xml'
+
+```xml
+
+
+
+ youtube
+
+
+ netflix
+
+
+ jellyfin
+
+
+ amazon__video // note the __
+
+
+```
+
+### Switch type action configs example:
+
+This example contains the files to add actions for turning a switch on or off.
+These actions will target all Switch items.
+
+#### File '/actiontemplatehli/type_actions/Switch.json'
+
+```json
+[
+ {
+ "template": "$onOff $itemLabel",
+ "value": "$onOff",
+ "type": "tokens",
+ "placeholders": [
+ {
+ "label": "onOff",
+ "nerValues": [
+ "turn on",
+ "turn off"
+ ],
+ "posValues": {
+ "turn__on": "ON", // note the __
+ "turn__off": "OFF"
+ }
+ }
+ ]
+ },
+ {
+ "template": "how be the $itemLabel",
+ "read": true,
+ "type": "lemmas",
+ "value": "$itemLabel is $state"
+ }
+]
+```
+
+### Switch item action configs example:
+
+This example contains the item metadata to add an action to an item with type 'Switch''.
+Add a custom metadata 'actiontemplatehli' to the 'Switch' item with the following:
+
+```yaml
+value: ""
+config:
+ placeholders:
+ - label: onOff
+ nerValues:
+ - turn on
+ - turn off
+ posValues:
+ turn__on: ON
+ turn__off: OFF
+ template: $onOff $itemLabel
+ type: tokens
+ value: $onOff
+```
+
+### Dynamic placeholder, sending a message example:
+
+This example contains the item metadata to add an action that uses the dynamic placeholder.
+Add a custom metadata 'actiontemplatehli' to a String item with the following:
+
+```yaml
+value: ""
+config:
+ placeholders:
+ - label: contact
+ nerValues:
+ - Andrea
+ - Jacob
+ - Raquel
+ silent: true
+ template: send message $* to $contact
+ type: tokens
+ value: $contact:$*
+```
diff --git a/bundles/org.openhab.voice.actiontemplatehli/pom.xml b/bundles/org.openhab.voice.actiontemplatehli/pom.xml
new file mode 100644
index 0000000000000..68526e314f1b9
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/pom.xml
@@ -0,0 +1,48 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 3.3.0-SNAPSHOT
+
+
+ org.openhab.voice.actiontemplatehli
+
+ openHAB Add-ons :: Bundles :: Voice :: Action Template Interpreter
+
+
+ jackson-core,jackson-annotations,jackson-databind
+
+
+
+
+
+ org.apache.opennlp
+ opennlp-tools
+ 2.0.0
+ compile
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ ${jackson.version}
+ compile
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ ${jackson.version}
+ compile
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+ compile
+
+
+
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/feature/feature.xml b/bundles/org.openhab.voice.actiontemplatehli/src/main/feature/feature.xml
new file mode 100644
index 0000000000000..7bc9a3d3324a6
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.voice.actiontemplatehli/${project.version}
+
+
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreter.java b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreter.java
new file mode 100644
index 0000000000000..75584af20dc51
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreter.java
@@ -0,0 +1,1256 @@
+/**
+ * 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.voice.actiontemplatehli.internal;
+
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.DYNAMIC_PLACEHOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.DYNAMIC_PLACEHOLDER_SYMBOL;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.GROUP_LABEL_PLACEHOLDER_SYMBOL;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_LABEL_PLACEHOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_LABEL_PLACEHOLDER_SYMBOL;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_OPTION_PLACEHOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.ITEM_OPTION_PLACEHOLDER_SYMBOL;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.NER_FOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.NLP_FOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.POS_FOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_CATEGORY;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_ID;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_NAME;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_PID;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.STATE_PLACEHOLDER;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.STATE_PLACEHOLDER_SYMBOL;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.TYPE_ACTION_CONFIGS_FOLDER;
+
+import java.awt.Color;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.common.registry.RegistryChangeListener;
+import org.openhab.core.config.core.ConfigurableService;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.items.GroupItem;
+import org.openhab.core.items.Item;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.items.Metadata;
+import org.openhab.core.items.MetadataKey;
+import org.openhab.core.items.MetadataRegistry;
+import org.openhab.core.items.events.ItemEventFactory;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.OpenClosedType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.TypeParser;
+import org.openhab.core.types.UnDefType;
+import org.openhab.core.voice.text.HumanLanguageInterpreter;
+import org.openhab.core.voice.text.InterpretationException;
+import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplateConfiguration;
+import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplateGroupTargets;
+import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplatePlaceholder;
+import org.osgi.framework.Constants;
+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.Modified;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import opennlp.tools.dictionary.Dictionary;
+import opennlp.tools.lemmatizer.DictionaryLemmatizer;
+import opennlp.tools.lemmatizer.Lemmatizer;
+import opennlp.tools.lemmatizer.LemmatizerME;
+import opennlp.tools.lemmatizer.LemmatizerModel;
+import opennlp.tools.namefind.DictionaryNameFinder;
+import opennlp.tools.namefind.NameFinderME;
+import opennlp.tools.namefind.TokenNameFinderModel;
+import opennlp.tools.postag.POSDictionary;
+import opennlp.tools.postag.POSModel;
+import opennlp.tools.postag.POSTaggerME;
+import opennlp.tools.tokenize.SimpleTokenizer;
+import opennlp.tools.tokenize.Tokenizer;
+import opennlp.tools.tokenize.TokenizerME;
+import opennlp.tools.tokenize.TokenizerModel;
+import opennlp.tools.tokenize.WhitespaceTokenizer;
+import opennlp.tools.util.Span;
+import opennlp.tools.util.StringList;
+
+/**
+ * The {@link ActionTemplateInterpreter} is a configurable interpreter powered by OpenNLP
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + SERVICE_PID)
+@ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME, description_uri = SERVICE_CATEGORY + ":"
+ + SERVICE_ID)
+public class ActionTemplateInterpreter implements HumanLanguageInterpreter {
+ static {
+ Logger logger = LoggerFactory.getLogger(ActionTemplateInterpreter.class);
+ createFolder(logger, NLP_FOLDER);
+ createFolder(logger, NER_FOLDER);
+ createFolder(logger, POS_FOLDER);
+ createFolder(logger, TYPE_ACTION_CONFIGS_FOLDER);
+ }
+ private static final Pattern COLOR_HEX_PATTERN = Pattern.compile("^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$");
+ private final Logger logger = LoggerFactory.getLogger(ActionTemplateInterpreter.class);
+ private final ItemRegistry itemRegistry;
+ private final MetadataRegistry metadataRegistry;
+ private final EventPublisher eventPublisher;
+ private ActionTemplateInterpreterConfiguration config = new ActionTemplateInterpreterConfiguration();
+ private Tokenizer tokenizer = WhitespaceTokenizer.INSTANCE;
+ private List optionalLanguageTags = List.of();
+ @Nullable
+ private NLPItemMaps nlpItemMaps;
+
+ private final RegistryChangeListener registryChangeListener = new RegistryChangeListener<>() {
+ @Override
+ public void added(Item element) {
+ invalidate();
+ }
+
+ @Override
+ public void removed(Item element) {
+ invalidate();
+ }
+
+ @Override
+ public void updated(Item oldElement, Item element) {
+ invalidate();
+ }
+ };
+
+ @Activate
+ public ActionTemplateInterpreter(@Reference ItemRegistry itemRegistry, @Reference MetadataRegistry metadataRegistry,
+ @Reference EventPublisher eventPublisher) {
+ this.itemRegistry = itemRegistry;
+ this.metadataRegistry = metadataRegistry;
+ this.eventPublisher = eventPublisher;
+ itemRegistry.addRegistryChangeListener(registryChangeListener);
+ }
+
+ @Activate
+ protected void activate(Map config) {
+ modified(config);
+ }
+
+ @Modified
+ protected void modified(Map config) {
+ this.config = new Configuration(config).as(ActionTemplateInterpreterConfiguration.class);
+ reloadConfigs();
+ }
+
+ @Deactivate
+ protected void deactivate() {
+ itemRegistry.removeRegistryChangeListener(registryChangeListener);
+ }
+
+ @Override
+ public String getId() {
+ return SERVICE_ID;
+ }
+
+ @Override
+ public String getLabel(@Nullable Locale locale) {
+ return SERVICE_NAME;
+ }
+
+ @Override
+ public @Nullable String getGrammar(@Nullable Locale locale, @Nullable String s) {
+ return null;
+ }
+
+ @Override
+ public Set getSupportedLocales() {
+ // all are supported
+ return Set.of();
+ }
+
+ @Override
+ public Set getSupportedGrammarFormats() {
+ return Set.of();
+ }
+
+ @Override
+ public String interpret(Locale locale, String words) throws InterpretationException {
+ if (words.isEmpty()) {
+ throw new InterpretationException(config.unhandledMessage);
+ }
+ try {
+ var finalWords = config.lowerText ? words.toLowerCase(locale) : words;
+ var info = getNLPInfo(finalWords);
+ if (info.tokens.length == 0) {
+ logger.debug("no tokens produced; aborting");
+ throw new InterpretationException(config.failureMessage);
+ }
+ String response = processAction(finalWords,
+ checkActionConfigs(finalWords, info.tokens, info.tags, info.lemmas));
+ if (response == null) {
+ logger.debug("silent mode; no response");
+ return "";
+ }
+ logger.debug("response: {}", response);
+ return response;
+ } catch (IOException e) {
+ logger.debug("IOException while interpreting: {}", e.getMessage(), e);
+ var message = e.getMessage();
+ throw new InterpretationException(message != null ? message : "Unknown error");
+ } catch (RuntimeException e) {
+ var message = e.getMessage();
+ logger.debug("RuntimeException while interpreting: {}", e.getMessage(), e);
+ throw new InterpretationException(message != null ? message : "Unknown error");
+ }
+ }
+
+ private @Nullable String processAction(String words, @Nullable NLPInterpretationResult result)
+ throws InterpretationException, IOException {
+ if (result != null) {
+ if (!result.actionConfig.read) {
+ return sendItemCommand(result.targetItem, words, result.actionConfig, result.placeholderValues);
+ } else {
+ return readItemState(result.targetItem, result.actionConfig);
+ }
+ } else {
+ throw new InterpretationException(config.unhandledMessage);
+ }
+ }
+
+ private NLPInfo getNLPInfo(String text) throws IOException {
+ logger.debug("Processing: '{}'", text);
+ var tokens = tokenizeText(text);
+ var tags = languagePOSTagging(tokens);
+ var lemmas = languageLemmatize(tokens, tags);
+ logger.debug("tokens: {}", List.of(tokens));
+ logger.debug("tags: {}", List.of(tags));
+ logger.debug("lemmas: {}", List.of(lemmas));
+ return new NLPInfo(tokens, lemmas, tags);
+ }
+
+ private @Nullable NLPInterpretationResult checkActionConfigs(String text, String[] tokens, String[] tags,
+ String[] lemmas) throws IOException {
+ // item defined actions have priority over type defined actions
+ var result = checkItemActions(text, tokens, tags, lemmas);
+ if (result != null) {
+ return result;
+ }
+ return checkTypeActionsConfigs(text, tokens, tags, lemmas);
+ }
+
+ private @Nullable NLPInterpretationResult checkItemActions(String text, String[] tokens, String[] tags,
+ String[] lemmas) throws IOException {
+ // Check item with action config
+ var itemsWithActions = getItemsWithActionConfigs();
+ Item targetItem = null;
+ ActionTemplateConfiguration targetActionConfig = null;
+ // store data to restore placeholder values
+ List placeholderValues = null;
+ // store span of dynamic placeholder, to invalidate others
+ Span dynamicSpan = null;
+ int matchScore = 0;
+ for (var entry : itemsWithActions.entrySet()) {
+ var actionConfigs = entry.getValue();
+ for (var actionConfig : actionConfigs) {
+ var templates = Arrays.stream(actionConfig.template.split(";")).map(String::trim)
+ .collect(Collectors.toList());
+ for (var template : templates) {
+ List currentPlaceholderValues = new ArrayList<>();
+ var currentItem = entry.getKey();
+ var scoreResult = getScoreWithPlaceholders(text, currentItem, actionConfig.memberTargets,
+ actionConfig.read, tokens, tags, lemmas, actionConfig, template, currentPlaceholderValues);
+ if (scoreResult.score != 0 && scoreResult.score == matchScore) {
+ if (targetItem == currentItem) {
+ logger.warn(
+ "multiple alternative templates for item '{}' has the same score, '{}' can be removed",
+ targetItem.getName(), template);
+ } else {
+ logger.warn(
+ "multiple templates with same score for items '{}' and '{}', the action with template '{}' can be removed",
+ targetItem.getName(), currentItem.getName(), template);
+ }
+ }
+ if (scoreResult.score > matchScore) {
+ targetItem = currentItem;
+ targetActionConfig = actionConfig;
+ placeholderValues = currentPlaceholderValues;
+ matchScore = scoreResult.score;
+ dynamicSpan = scoreResult.dynamicSpan;
+ }
+ }
+ }
+ }
+ if (targetItem != null && targetActionConfig != null && placeholderValues != null) {
+ if (dynamicSpan != null) {
+ placeholderValues = updatePlaceholderValues(text, tokens, placeholderValues, dynamicSpan);
+ }
+ return new NLPInterpretationResult(targetItem, targetActionConfig, placeholderValues.stream()
+ .collect(Collectors.toMap(i -> i.placeholderName, i -> i.placeholderValue)));
+ }
+ return null;
+ }
+
+ private @Nullable NLPInterpretationResult checkTypeActionsConfigs(String text, String[] tokens, String[] tags,
+ String[] lemmas) throws IOException {
+ // Check item command
+ var itemLabelSpans = nerItemLabels(tokens);
+ logger.debug("itemLabelSpans: {}", List.of(itemLabelSpans));
+ if (itemLabelSpans.length == 0) {
+ logger.debug("No item labels found!");
+ return null;
+ }
+ Item finalTargetItem = null;
+ ActionTemplateConfiguration targetActionConfig = null;
+ // store data to restore placeholder values
+ List placeholderValues = null;
+ // store span of dynamic placeholder, to invalidate others
+ Span dynamicSpan = null;
+ int matchScore = 0;
+ // iterate itemLabelSpan to score the templates with each of them
+ for (var itemLabelSpan : itemLabelSpans) {
+ var labelTokens = getTargetItemTokens(tokens, itemLabelSpan);
+ var targetItem = getTargetItemByLabelTokens(labelTokens);
+ if (targetItem == null) {
+ return null;
+ }
+ var tokensWithGenericLabel = replacePlaceholder(text, tokens, itemLabelSpan, ITEM_LABEL_PLACEHOLDER, null,
+ null);
+ var lemmasWithGenericLabel = lemmas.length > 0
+ ? replacePlaceholder(text, lemmas, itemLabelSpan, ITEM_LABEL_PLACEHOLDER, null, null)
+ : new String[] {};
+ var tagsWithGenericLabel = tags.length > 0
+ ? replacePlaceholder(text, tags, itemLabelSpan, ITEM_LABEL_PLACEHOLDER, null, null)
+ : new String[] {};
+ logger.debug("Target item {}", targetItem.getName());
+ // load templates defined for this item type
+ var typeActionConfigs = getTypeActionConfigs(targetItem.getType());
+ for (var actionConfig : typeActionConfigs) {
+ // check required item tags
+ if (actionConfig.requiredItemTags.length != 0) {
+ var itemLabels = targetItem.getTags();
+ if (!Arrays.stream(actionConfig.requiredItemTags).allMatch(itemLabels::contains)) {
+ logger.debug("action '{}' skipped, tags constrain '{}'", actionConfig.template,
+ List.of(actionConfig.requiredItemTags));
+ continue;
+ }
+ }
+ var templates = Arrays.stream(actionConfig.template.split(";")).map(String::trim)
+ .collect(Collectors.toList());
+ for (var template : templates) {
+ var replacedValues = new ArrayList();
+ var scoreResult = getScoreWithPlaceholders(text, targetItem, actionConfig.memberTargets,
+ actionConfig.read, tokensWithGenericLabel, tagsWithGenericLabel, lemmasWithGenericLabel,
+ actionConfig, template, replacedValues);
+ if (scoreResult.score != 0 && scoreResult.score == matchScore
+ && actionConfig.requiredItemTags.length == targetActionConfig.requiredItemTags.length) {
+ if (targetActionConfig == actionConfig) {
+ logger.warn(
+ "multiple alternative templates with same score, you can remove the alternative '{}'",
+ template);
+ } else {
+ logger.warn(
+ "multiple templates with same score, the action with template '{}' can be removed",
+ template);
+ }
+ }
+ // for rules with same score the one with more restrictions have prevalence
+ if (scoreResult.score > matchScore || (scoreResult.score == matchScore && targetActionConfig != null
+ && actionConfig.requiredItemTags.length > targetActionConfig.requiredItemTags.length)) {
+ finalTargetItem = targetItem;
+ placeholderValues = replacedValues;
+ targetActionConfig = actionConfig;
+ matchScore = scoreResult.score;
+ dynamicSpan = scoreResult.dynamicSpan;
+ }
+ }
+ }
+ }
+ if (finalTargetItem != null && targetActionConfig != null && placeholderValues != null) {
+ if (dynamicSpan != null) {
+ placeholderValues = updatePlaceholderValues(text, tokens, placeholderValues, dynamicSpan);
+ }
+ return NLPInterpretationResult.from(finalTargetItem, targetActionConfig, placeholderValues);
+ }
+ return null;
+ }
+
+ private List updatePlaceholderValues(String text, String[] tokens,
+ List placeholderValues, Span dynamicSpan) {
+ // we should clean up placeholder values detected inside the dynamic template
+ var validPlaceholderValues = placeholderValues.stream().filter(i -> !dynamicSpan.contains(i.placeholderSpan))
+ .collect(Collectors.toList());
+ // add dynamic content to values
+ validPlaceholderValues.add(new NLPPlaceholderData(DYNAMIC_PLACEHOLDER,
+ detokenize(Arrays.copyOfRange(tokens, dynamicSpan.getStart(), dynamicSpan.getEnd()), text),
+ dynamicSpan));
+ return validPlaceholderValues;
+ }
+
+ private Set getMembersByTypeRecursive(GroupItem group, String itemType, String[] requiredMemberTags) {
+ Stream targetMembersStream = getMembersByType(group, itemType, requiredMemberTags).stream();
+ var childGroups = getMembersByType(group, "Group", new String[] {});
+ for (var childGroup : childGroups) {
+ targetMembersStream = Stream.concat(targetMembersStream,
+ getMembersByTypeRecursive((GroupItem) childGroup, itemType, requiredMemberTags).stream());
+ }
+ return targetMembersStream.collect(Collectors.toUnmodifiableSet());
+ }
+
+ private State mergeSwitchMembersState(GroupItem group, String[] requiredMemberTags, boolean recursive) {
+ var result = OnOffType.OFF;
+ var targetMembers = recursive ? getMembersByTypeRecursive(group, "Switch", requiredMemberTags)
+ : getMembersByType(group, "Switch", requiredMemberTags);
+ for (var member : targetMembers) {
+ if (UnDefType.UNDEF.equals(member.getState())) {
+ return UnDefType.UNDEF;
+ }
+ if (UnDefType.NULL.equals(member.getState())) {
+ return UnDefType.NULL;
+ }
+ if (OnOffType.ON.equals(member.getState())) {
+ result = OnOffType.ON;
+ }
+ }
+ return result;
+ }
+
+ private State mergeContactMembersState(GroupItem group, String[] requiredMemberTags, boolean recursive) {
+ var result = OpenClosedType.CLOSED;
+ var targetMembers = recursive ? getMembersByTypeRecursive(group, "Contact", requiredMemberTags)
+ : getMembersByType(group, "Contact", requiredMemberTags);
+ for (var member : targetMembers) {
+ if (UnDefType.UNDEF.equals(member.getState())) {
+ return UnDefType.UNDEF;
+ }
+ if (UnDefType.NULL.equals(member.getState())) {
+ return UnDefType.NULL;
+ }
+ if (OpenClosedType.OPEN.equals(member.getState())) {
+ result = OpenClosedType.OPEN;
+ }
+ }
+ return result;
+ }
+
+ private String readItemState(Item targetItem, ActionTemplateConfiguration actionConfigMatch)
+ throws IOException, InterpretationException {
+ var memberTargets = actionConfigMatch.memberTargets;
+ String state = null;
+ String itemLabel = targetItem.getLabel();
+ String groupLabel = null;
+ Item finalTargetItem = targetItem;
+ if (finalTargetItem.getType().equals("Group") && memberTargets != null) {
+ if (memberTargets.mergeState && memberTargets.itemName.isEmpty() && !memberTargets.itemType.isEmpty()) {
+ // handle states that can be merged
+ switch (memberTargets.itemType) {
+ case "Switch":
+ state = mergeSwitchMembersState((GroupItem) finalTargetItem, memberTargets.requiredItemTags,
+ memberTargets.recursive).toFullString();
+ break;
+ case "Contact":
+ state = mergeContactMembersState((GroupItem) finalTargetItem, memberTargets.requiredItemTags,
+ memberTargets.recursive).toFullString();
+ break;
+ default:
+ logger.warn("state merge is not available for members of type {}", memberTargets.itemType);
+ throw new InterpretationException(config.failureMessage);
+ }
+ }
+ if (state == null) {
+ Set targetMembers = getTargetMembers((GroupItem) finalTargetItem, memberTargets);
+ if (!targetMembers.isEmpty()) {
+ if (targetMembers.size() > 1) {
+ logger.warn("read action matches {} item members inside a group, using the first one",
+ targetMembers.size());
+ }
+ var targetMember = targetMembers.iterator().next();
+ // only one target in the group, adding groupLabel placeholder value
+ groupLabel = itemLabel;
+ itemLabel = targetMember.getLabel();
+ state = targetMember.getState().toFullString();
+ finalTargetItem = targetMember;
+ } else {
+ logger.warn("configured targetMembers were not found in group '{}'", finalTargetItem.getName());
+ throw new InterpretationException(config.failureMessage);
+ }
+ }
+ }
+ if (state == null) {
+ state = finalTargetItem.getState().toFullString();
+ }
+ var statePlaceholder = actionConfigMatch.placeholders.stream().filter(p -> p.label.equals(STATE_PLACEHOLDER))
+ .findFirst();
+ var itemState = state;
+ if (statePlaceholder.isPresent()) {
+ state = applyPOSTransformation(state, statePlaceholder.get());
+ }
+ var template = actionConfigMatch.value;
+ if (!actionConfigMatch.emptyValue.isEmpty() && (state.isEmpty()
+ || (UnDefType.UNDEF.toFullString().equals(state) || UnDefType.NULL.toFullString().equals(state)))) {
+ // use alternative template for empty values
+ template = actionConfigMatch.emptyValue;
+ }
+ if (template instanceof String) {
+ String templateText = (String) template;
+ if (templateText.contains(ITEM_OPTION_PLACEHOLDER)) {
+ var itemOptionPlaceholder = getItemOptionPlaceholder(finalTargetItem, true, null);
+ if (itemOptionPlaceholder != null) {
+ itemState = applyPOSTransformation(itemState, itemOptionPlaceholder);
+ }
+ }
+ return templateText.replace(STATE_PLACEHOLDER_SYMBOL, state)
+ .replace(ITEM_OPTION_PLACEHOLDER_SYMBOL, itemState)
+ .replace(ITEM_LABEL_PLACEHOLDER_SYMBOL, itemLabel != null ? itemLabel : "")
+ .replace(GROUP_LABEL_PLACEHOLDER_SYMBOL, groupLabel != null ? groupLabel : "");
+ }
+ return state;
+ }
+
+ private NLPTokenComparisonResult getScoreWithPlaceholders(String text, Item targetItem,
+ @Nullable ActionTemplateGroupTargets targetMembers, boolean isRead, String[] tokens, String[] tags,
+ String[] lemmas, ActionTemplateConfiguration actionConfiguration, String template,
+ List placeholderValues) throws IOException {
+ var placeholders = new ArrayList<>(actionConfiguration.placeholders);
+ var finalTokens = tokens;
+ var finalLemmas = lemmas;
+ var finalTags = tags;
+ if (template.contains(ITEM_OPTION_PLACEHOLDER_SYMBOL)) {
+ var itemOptionPlaceholder = getItemOptionPlaceholder(targetItem, isRead, targetMembers);
+ if (itemOptionPlaceholder == null) {
+ return NLPTokenComparisonResult.ZERO;
+ }
+ placeholders.add(itemOptionPlaceholder);
+ }
+ for (var placeholder : placeholders) {
+ if (actionConfiguration.read && placeholder.label.equals(STATE_PLACEHOLDER)) {
+ // This placeholder is reserved on read mode should not be replaced now
+ continue;
+ }
+ if (placeholder.label.equals(DYNAMIC_PLACEHOLDER)) {
+ logger.warn("the name {} is reserved for the dynamic placeholder", DYNAMIC_PLACEHOLDER);
+ continue;
+ }
+ var nerStaticValues = placeholder.nerStaticValues;
+ var nerFile = placeholder.nerFile;
+ Span[] nerSpans;
+ Map possibleValuesByTokensMap = null;
+ if (nerStaticValues != null) {
+ possibleValuesByTokensMap = getStringsByTokensMap(nerStaticValues);
+ nerSpans = nerValues(finalTokens, possibleValuesByTokensMap.keySet().toArray(String[][]::new),
+ placeholder.label);
+ } else if (nerFile != null) {
+ nerSpans = nerWithFile(finalTokens, nerFile);
+ } else {
+ logger.warn("Placeholder {} could not be applied due to missing ner config", placeholder.label);
+ continue;
+ }
+ for (Span nerSpan : nerSpans) {
+ var placeholderName = placeholder.label;
+ finalTokens = replacePlaceholder(text, finalTokens, nerSpan, placeholderName, placeholderValues,
+ possibleValuesByTokensMap);
+ if (finalLemmas.length > 0) {
+ finalLemmas = replacePlaceholder(text, finalLemmas, nerSpan, placeholderName, null, null);
+ }
+ if (finalTags.length > 0) {
+ finalTags = replacePlaceholder(text, finalTags, nerSpan, placeholderName, null, null);
+ }
+ }
+ }
+ return getScore(finalTokens, finalTags, finalLemmas, actionConfiguration, template);
+ }
+
+ private NLPTokenComparisonResult getScore(String[] tokens, String[] tags, String[] lemmas,
+ ActionTemplateConfiguration actionConfiguration, String template) {
+ switch (actionConfiguration.type) {
+ case "tokens":
+ String[] tokensTemplate = splitString(template, "\\s");
+ var scoreByTokens = compareTokens(tokens, tags, tokensTemplate);
+ logger.debug("tokens '{}' score: {}", List.of(tokensTemplate), scoreByTokens.score);
+ return scoreByTokens;
+ case "lemmas":
+ String[] lemmasTemplate = splitString(template, "\\s");
+ var scoreByLemmas = compareTokens(lemmas, tags, lemmasTemplate);
+ logger.debug("lemmas '{}' score: {}", List.of(lemmasTemplate), scoreByLemmas.score);
+ return scoreByLemmas;
+ default:
+ logger.warn("Unsupported template type '{}'", actionConfiguration.type);
+ return NLPTokenComparisonResult.ZERO;
+ }
+ }
+
+ private @Nullable String sendItemCommand(Item item, String text, ActionTemplateConfiguration actionConfiguration,
+ Map placeholderValues) throws IOException, InterpretationException {
+ Object valueTemplate = actionConfiguration.value;
+ boolean silent = actionConfiguration.silent;
+ String replacedValue = null;
+ Command command = null;
+ // Special type handling
+ switch (item.getType()) {
+ case "Color":
+ if (valueTemplate instanceof String) {
+ replacedValue = templatePlaceholders((String) valueTemplate, item, placeholderValues,
+ actionConfiguration.placeholders);
+ if (COLOR_HEX_PATTERN.matcher(replacedValue).matches()) {
+ Color rgb = Color.decode(replacedValue);
+ try {
+ command = HSBType.fromRGB(rgb.getRed(), rgb.getGreen(), rgb.getBlue());
+ } catch (NumberFormatException e) {
+ logger.warn("Unable to parse value '{}' as color", replacedValue);
+ throw new InterpretationException(config.failureMessage);
+ }
+ }
+ }
+ break;
+ case "Group":
+ var groupItem = (GroupItem) item;
+ var memberTargetsConfig = actionConfiguration.memberTargets;
+ if (memberTargetsConfig != null) {
+ Set targetMembers = getTargetMembers(groupItem, memberTargetsConfig);
+ logger.debug("{} target members were found in group {}", targetMembers.size(), groupItem.getName());
+ if (!targetMembers.isEmpty()) {
+ // swap the command target by the matched members
+ boolean ok = true;
+ boolean groupsilent = true;
+ for (var targetMember : targetMembers) {
+ var response = sendItemCommand(targetMember, text, actionConfiguration, placeholderValues);
+ if (config.failureMessage.equals(response)) {
+ ok = false;
+ }
+ if (response != null) {
+ groupsilent = false;
+ }
+ }
+ return ok ? (groupsilent ? null : config.commandSentMessage) : config.failureMessage;
+ } else {
+ logger.warn("configured targetMembers were not found in group '{}'", groupItem.getName());
+ throw new InterpretationException(config.failureMessage);
+ }
+ }
+ break;
+ }
+ if (command == null) {
+ // Common behavior
+ var objectValue = actionConfiguration.value;
+ if (objectValue != null) {
+ if (replacedValue == null) {
+ var stringValue = String.valueOf(objectValue);
+ replacedValue = templatePlaceholders(stringValue, item, placeholderValues,
+ actionConfiguration.placeholders);
+ }
+ command = TypeParser.parseCommand(item.getAcceptedCommandTypes(), replacedValue);
+ } else if ("String".equals(item.getType())) {
+ // We interpret processing will continue in a rule
+ silent = true;
+ command = new StringType(text);
+ }
+ }
+ if (command == null) {
+ logger.warn("Command '{}' is not valid for item '{}'.", actionConfiguration.value, item.getName());
+ throw new InterpretationException(config.failureMessage);
+ }
+ eventPublisher.post(ItemEventFactory.createCommandEvent(item.getName(), command));
+ if (silent) {
+ // when silent mode give no result
+ return null;
+ } else {
+ return config.commandSentMessage;
+ }
+ }
+
+ private @Nullable ActionTemplatePlaceholder getItemOptionPlaceholder(Item targetItem, boolean isRead,
+ @Nullable ActionTemplateGroupTargets memberTargets) {
+ if ("Group".equals(targetItem.getType()) && memberTargets != null) {
+ var targetMembers = getTargetMembers((GroupItem) targetItem, memberTargets);
+ logger.debug("{} target members were found in group {}", targetMembers.size(), targetItem.getName());
+ if (!targetMembers.isEmpty()) {
+ return targetMembers.stream().map(member -> getItemOptionPlaceholder(member, isRead, null))
+ .reduce(ActionTemplatePlaceholder.withLabel(ITEM_OPTION_PLACEHOLDER), (a, b) -> {
+ a.nerStaticValues = a.nerStaticValues != null
+ ? Stream.concat(Arrays.stream(a.nerStaticValues), Arrays.stream(b.nerStaticValues))
+ .distinct().toArray(String[]::new)
+ : b.nerStaticValues;
+ return a;
+ });
+ }
+ }
+ var cmdDescription = targetItem.getCommandDescription();
+ var stateDescription = targetItem.getStateDescription();
+ var itemOptionPlaceholder = ActionTemplatePlaceholder.withLabel(ITEM_OPTION_PLACEHOLDER);
+ if (!isRead && cmdDescription != null) {
+ itemOptionPlaceholder.nerStaticValues = cmdDescription.getCommandOptions().stream()
+ .map(option -> option.getLabel() != null ? option.getLabel() : option.getCommand())
+ .filter(Objects::nonNull).toArray(String[]::new);
+ itemOptionPlaceholder.posStaticValues = cmdDescription.getCommandOptions().stream()
+ .collect(Collectors.toMap(
+ option -> option.getLabel() != null ? option.getLabel().replaceAll(" ", "__")
+ : option.getCommand().replaceAll(" ", "__"),
+ option -> option.getCommand().replaceAll(" ", "__")));
+ return itemOptionPlaceholder;
+ } else if (stateDescription != null) {
+ itemOptionPlaceholder.nerStaticValues = stateDescription.getOptions().stream()
+ .map(option -> option.getLabel() != null ? option.getLabel() : option.getValue())
+ .filter(Objects::nonNull).toArray(String[]::new);
+ if (isRead) {
+ itemOptionPlaceholder.posStaticValues = stateDescription.getOptions().stream()
+ .collect(Collectors.toMap(option -> option.getValue().replaceAll(" ", "__"),
+ option -> option.getLabel() != null ? option.getLabel().replaceAll(" ", "__")
+ : option.getValue().replaceAll(" ", "__")));
+ } else {
+ itemOptionPlaceholder.posStaticValues = stateDescription.getOptions().stream()
+ .collect(Collectors.toMap(
+ option -> option.getLabel() != null ? option.getLabel().replaceAll(" ", "__")
+ : option.getValue().replaceAll(" ", "__"),
+ option -> option.getValue().replaceAll(" ", "__")));
+ }
+ return itemOptionPlaceholder;
+ }
+ logger.warn(
+ "'{}' is the target item for an action that uses the '{}' placeholder but hasn't got any state/command description",
+ targetItem.getName(), ITEM_OPTION_PLACEHOLDER_SYMBOL);
+ return null;
+ }
+
+ private Set getTargetMembers(GroupItem groupItem, ActionTemplateGroupTargets memberTargets) {
+ var childName = memberTargets.itemName;
+ if (!childName.isEmpty()) {
+ return groupItem.getMembers(i -> i.getName().equals(childName));
+ }
+ var itemType = memberTargets.itemType;
+ var requiredItemTags = memberTargets.requiredItemTags;
+ if (!itemType.isEmpty()) {
+ return memberTargets.recursive ? getMembersByTypeRecursive(groupItem, itemType, requiredItemTags)
+ : getMembersByType(groupItem, itemType, requiredItemTags);
+ }
+ return Set.of();
+ }
+
+ private Set getMembersByType(GroupItem groupItem, String itemType, String[] requiredItemTags) {
+ return groupItem.getMembers(i -> i.getType().equals(itemType)
+ && (requiredItemTags.length == 0 || Arrays.stream(requiredItemTags).allMatch(i.getTags()::contains)));
+ }
+
+ private String templatePlaceholders(String text, Item targetItem, Map placeholderValues,
+ List placeholders) throws IOException {
+ var placeholdersCopy = new ArrayList<>(placeholders);
+ if (placeholderValues.containsKey(ITEM_OPTION_PLACEHOLDER)) {
+ var itemOptionPlaceholder = getItemOptionPlaceholder(targetItem, false, null);
+ if (itemOptionPlaceholder != null) {
+ placeholdersCopy.add(itemOptionPlaceholder);
+ }
+ }
+ String finalText = text;
+ // replace placeholder symbols
+ for (var placeholder : placeholdersCopy) {
+ var placeholderValue = placeholderValues.getOrDefault(placeholder.label, "");
+ if (!placeholderValue.isBlank()) {
+ placeholderValue = applyPOSTransformation(placeholderValue, placeholder);
+ }
+ finalText = finalText.replace(getPlaceholderSymbol(placeholder.label), placeholderValue);
+ }
+ // replace dynamic placeholder symbol
+ var dynamicValue = placeholderValues.getOrDefault(DYNAMIC_PLACEHOLDER, "");
+ if (!dynamicValue.isBlank()) {
+ finalText = finalText.replace(DYNAMIC_PLACEHOLDER_SYMBOL, dynamicValue);
+ }
+ return finalText;
+ }
+
+ protected ActionTemplateConfiguration[] getTypeActionConfigs(String itemType) {
+ File actionConfigsFile = Path.of(TYPE_ACTION_CONFIGS_FOLDER, itemType + ".json").toFile();
+ logger.debug("loading action templates configuration file {}", actionConfigsFile);
+ if (actionConfigsFile.exists() && !actionConfigsFile.isDirectory()) {
+ try {
+ return ActionTemplateConfiguration.fromJSON(actionConfigsFile);
+ } catch (IOException e) {
+ logger.warn("unable to parse action templates configuration for type {}: {}", itemType, e.getMessage());
+ }
+ }
+ logger.debug("action templates configuration for type {} not available", itemType);
+ return new ActionTemplateConfiguration[] {};
+ }
+
+ private @Nullable Item getTargetItemByLabelTokens(String[] tokens) {
+ var label = getItemsByLabelTokensMap().entrySet().stream()
+ .filter(entry -> Arrays.equals(tokens, entry.getKey())).findFirst();
+ if (label.isEmpty()) {
+ return null;
+ }
+ return label.get().getValue();
+ }
+
+ private String[] getTargetItemTokens(String[] tokens, Span itemLabelSpan) {
+ return Arrays.copyOfRange(tokens, itemLabelSpan.getStart(), itemLabelSpan.getEnd());
+ }
+
+ private String[] replacePlaceholder(String text, String[] tokens, Span span, String placeholderName,
+ @Nullable List replacements, @Nullable Map valueByTokensMap) {
+ if (replacements != null) {
+ var spanTokens = Arrays.copyOfRange(tokens, span.getStart(), span.getEnd());
+ String value;
+ if (valueByTokensMap != null) {
+ var match = valueByTokensMap.entrySet().stream()
+ .filter(entry -> Arrays.equals(spanTokens, entry.getKey())).findFirst();
+ if (match.isPresent()) {
+ value = match.get().getValue();
+ } else {
+ value = getSpanTokens(tokens, span, text);
+ }
+ } else {
+ value = getSpanTokens(tokens, span, text);
+ }
+ replacements.add(new NLPPlaceholderData(placeholderName, value, span));
+ }
+ var spanStart = span.getStart();
+ try (Stream dataStream = Stream.concat(
+ spanStart != 0 ? Arrays.stream(Arrays.copyOfRange(tokens, 0, spanStart)) : Stream.of(),
+ Arrays.stream(new String[] { getPlaceholderSymbol(placeholderName) }))) {
+ var spanEnd = span.getEnd();
+ if (spanEnd != tokens.length) {
+ return Stream.concat(dataStream, Arrays.stream(Arrays.copyOfRange(tokens, spanEnd, tokens.length)))
+ .toArray(String[]::new);
+ }
+ return dataStream.toArray(String[]::new);
+ }
+ }
+
+ private String getSpanTokens(String[] tokens, Span span, String original) {
+ return detokenize(Arrays.copyOfRange(tokens, span.getStart(), span.getEnd()), original);
+ }
+
+ private String detokenize(String[] tokens, String text) {
+ if (tokens.length == 1) {
+ return tokens[0];
+ }
+ if (config.detokenizeOptimization) {
+ // this is a dynamic regex to de-tokenize a part of the text based on the original text,
+ // this way we don't miss special characters between tokens.
+ var detokenizeRegex = String.join("[^a-bA-B0-9]?", tokens);
+ var match = Pattern.compile(detokenizeRegex).matcher(text);
+ if (match.find()) {
+ return match.group();
+ }
+ logger.warn("Unable to detokenize using build-in optimization, consider reporting this case");
+ }
+ // Detokenize should be improved in the future, Detokenizer API seems to be a work in progress in OpenNLP
+ return String.join(" ", tokens);
+ }
+
+ private Tokenizer getTokenizer() {
+ try {
+ Tokenizer tokenizer;
+ var tokenModelFile = Path.of(NLP_FOLDER, "token.bin").toFile();
+ if (tokenModelFile.exists()) {
+ logger.debug("Tokenizing with model {}", tokenModelFile);
+ InputStream inputStream = new FileInputStream(tokenModelFile);
+ TokenizerModel model = new TokenizerModel(inputStream);
+ tokenizer = new TokenizerME(model);
+ } else {
+ if (config.useSimpleTokenizer) {
+ logger.debug("Using simple tokenizer");
+ tokenizer = SimpleTokenizer.INSTANCE;
+ } else {
+ logger.debug("Using white space tokenizer");
+ tokenizer = WhitespaceTokenizer.INSTANCE;
+ }
+ }
+ return tokenizer;
+ } catch (IOException e) {
+ logger.warn("IOException while loading tokenizer: {}", e.getMessage());
+ if (config.useSimpleTokenizer) {
+ logger.warn("Fallback to simple tokenizer");
+ return SimpleTokenizer.INSTANCE;
+ } else {
+ logger.warn("Fallback to white space tokenizer");
+ return WhitespaceTokenizer.INSTANCE;
+ }
+ }
+ }
+
+ private String[] tokenizeText(String text) {
+ return tokenizer.tokenize(text);
+ }
+
+ private Span[] nerWithFile(String[] tokens, String placeholderFileName) throws IOException {
+ File nerModelFile = Path.of(NER_FOLDER, placeholderFileName + ".bin").toFile();
+ File nerDictionaryFile = Path.of(NER_FOLDER, placeholderFileName + ".xml").toFile();
+ if (nerModelFile.exists()) {
+ return nerWithModel(tokens, nerModelFile);
+ } else if (nerDictionaryFile.exists()) {
+ return nerWithDictionary(tokens, nerDictionaryFile, getPlaceholderSymbol(placeholderFileName));
+ } else {
+ logger.debug("No model or dictionary found for '{}'", placeholderFileName);
+ throw new IOException("Unable to find model or dictionary with name: " + placeholderFileName);
+ }
+ }
+
+ private Span[] nerItemLabels(String[] tokens) {
+ return nerValues(tokens, getItemsByLabelTokensMap().keySet().toArray(String[][]::new), ITEM_LABEL_PLACEHOLDER,
+ false);
+ }
+
+ private Span[] nerWithModel(String[] tokens, File nerModelFile) throws IOException {
+ logger.debug("applying NER with model {}", nerModelFile.getAbsolutePath());
+ TokenNameFinderModel model = new TokenNameFinderModel(nerModelFile);
+ var nameFinder = new NameFinderME(model);
+ return nameFinder.find(tokens);
+ }
+
+ private Span[] nerWithDictionary(String[] tokens, File nerDictFile, String type) throws IOException {
+ logger.debug("applying NER with dictionary {}", nerDictFile);
+ var dictionary = new opennlp.tools.dictionary.Dictionary(new FileInputStream(nerDictFile));
+ return nerWithDictionary(tokens, dictionary, type);
+ }
+
+ private Span[] nerValues(String[] tokens, String[][] valueTokens, String type) {
+ return nerValues(tokens, valueTokens, type, config.caseSensitive);
+ }
+
+ private Span[] nerValues(String[] tokens, String[][] valueTokens, String type, boolean caseSensitive) {
+ var runtimeDictionary = new Dictionary(caseSensitive);
+ Arrays.stream(valueTokens).map(StringList::new).forEach(runtimeDictionary::put);
+ return nerWithDictionary(tokens, runtimeDictionary, type);
+ }
+
+ private Span[] nerWithDictionary(String[] tokens, Dictionary dictionary, String type) {
+ var nameFinder = new DictionaryNameFinder(dictionary, type);
+ return nameFinder.find(tokens);
+ }
+
+ private String[] languagePOSTagging(String[] tokens) throws IOException {
+ var posTaggingModelFile = Path.of(NLP_FOLDER, "pos.bin").toFile();
+ if (posTaggingModelFile.exists()) {
+ logger.debug("applying POSTagging with model {}", posTaggingModelFile);
+ POSModel posModel = new POSModel(posTaggingModelFile);
+ POSTaggerME posTagger = new POSTaggerME(posModel);
+ return posTagger.tag(tokens);
+ } else {
+ logger.debug("POSTagging model not found {}, disabled", posTaggingModelFile);
+ return new String[] {};
+ }
+ }
+
+ private String[] languageLemmatize(String[] tokens, String[] tags) throws IOException {
+ if (tags.length == 0) {
+ logger.debug("Tags are required for lemmatization, disabled");
+ return new String[] {};
+ }
+ var lemmatizeModelFile = Path.of(NLP_FOLDER, "lemma.bin").toFile();
+ var lemmatizeDictionaryFile = Path.of(NLP_FOLDER, "lemma.txt").toFile();
+ Lemmatizer lemmatizer;
+ if (lemmatizeModelFile.exists()) {
+ logger.debug("applying lemmatize with model {}", lemmatizeModelFile);
+ LemmatizerModel model = new LemmatizerModel(lemmatizeModelFile);
+ lemmatizer = new LemmatizerME(model);
+ } else if (lemmatizeDictionaryFile.exists()) {
+ logger.debug("applying lemmatize with dictionary {}", lemmatizeDictionaryFile);
+ lemmatizer = new DictionaryLemmatizer(lemmatizeDictionaryFile);
+ } else {
+ logger.debug("Unable to find lemmatize dictionary or model, disabled");
+ return new String[] {};
+ }
+ return lemmatizer.lemmatize(tokens, tags);
+ }
+
+ private Map getStringsByTokensMap(String[] values) {
+ var map = new HashMap();
+ for (String value : values) {
+ map.put(tokenizeText(value), value);
+ }
+ return map;
+ }
+
+ private String applyPOSTransformation(String text, ActionTemplatePlaceholder placeholderConfig) throws IOException {
+ var singleWorldText = text.replaceAll("\\s", "__");
+ String tag = null;
+ if (placeholderConfig.posFile != null) {
+ File posTaggingDictionary = Path.of(POS_FOLDER, placeholderConfig.posFile + ".xml").toFile();
+ File posTaggingModel = Path.of(POS_FOLDER, placeholderConfig.posFile + ".bin").toFile();
+ if (posTaggingModel.exists()) {
+ POSModel posModel = new POSModel(posTaggingModel);
+ var tags = new POSTaggerME(posModel).tag(new String[] { singleWorldText });
+ if (tags.length > 0 && !"O".equals(tags[0])) {
+ tag = tags[0];
+ }
+ } else if (posTaggingDictionary.exists()) {
+ POSDictionary posDictionary = POSDictionary.create(new FileInputStream(posTaggingDictionary));
+ var tags = posDictionary.getTags(singleWorldText);
+ if (tags != null && tags.length > 0 && !"O".equals(tags[0])) {
+ tag = tags[0];
+ }
+ } else {
+ logger.warn("configured pos transformation file not found {}", placeholderConfig.posFile);
+ }
+ } else if (placeholderConfig.posStaticValues != null) {
+ var dictionary = new POSDictionary(config.caseSensitive);
+ for (var entry : placeholderConfig.posStaticValues.entrySet()) {
+ dictionary.put(entry.getKey(), entry.getValue());
+ }
+ var tokenTags = dictionary.getTags(singleWorldText);
+ if (tokenTags != null && tokenTags.length > 0) {
+ tag = tokenTags[0];
+ }
+ } else {
+ // no transformation configured
+ return text;
+ }
+ if (tag == null) {
+ return "";
+ }
+ return tag.replaceAll("__", " ");
+ }
+
+ private Map getItemsByLabelTokensMap() {
+ return getItemsMaps().itemLabelByTokens;
+ }
+
+ private Map getItemsWithActionConfigs() {
+ return getItemsMaps().itemsWithActionConfigs;
+ }
+
+ private NLPItemMaps getItemsMaps() {
+ var itemMaps = this.nlpItemMaps;
+ if (itemMaps == null) {
+ var itemByLabelTokens = new HashMap();
+ var itemsWithActionConfigs = new HashMap();
+ var labelList = new ArrayList();
+ for (Item item : itemRegistry.getAll()) {
+ var alternativeNames = new ArrayList();
+ var label = item.getLabel();
+ if (label != null) {
+ alternativeNames.add(label);
+ }
+ MetadataKey key = new MetadataKey("synonyms", item.getName());
+ Metadata synonymsMetadata = metadataRegistry.get(key);
+ if (synonymsMetadata != null) {
+ String[] synonyms = synonymsMetadata.getValue().split(",");
+ if (synonyms.length > 0) {
+ alternativeNames.addAll(List.of(synonyms));
+ }
+ }
+ if (!alternativeNames.isEmpty()) {
+ for (var alternative : alternativeNames) {
+ var lowerLabel = alternative.toLowerCase();
+ if (labelList.contains(lowerLabel)) {
+ logger.debug("Multiple items with label '{}', this is not supported, ignoring '{}'",
+ lowerLabel, item.getName());
+ continue;
+ }
+ labelList.add(lowerLabel);
+ itemByLabelTokens.put(tokenizeText(lowerLabel), item);
+ }
+ }
+ var metadata = metadataRegistry.get(new MetadataKey(SERVICE_ID, item.getName()));
+ if (metadata != null) {
+ try {
+ itemsWithActionConfigs.put(item, ActionTemplateConfiguration.fromMetadata(metadata));
+ } catch (IOException e) {
+ logger.warn("Unable to parse template action configs for item '{}': {}", item.getName(),
+ e.getMessage());
+ }
+ }
+ }
+ itemMaps = new NLPItemMaps(itemByLabelTokens, itemsWithActionConfigs);
+ this.nlpItemMaps = itemMaps;
+ }
+ return itemMaps;
+ }
+
+ private NLPTokenComparisonResult compareTokens(String[] tokens, String[] tokenTags, String[] tokensTemplate) {
+ if (tokens.length == 0 || tokensTemplate.length == 0) {
+ return NLPTokenComparisonResult.ZERO;
+ }
+ int score = 0;
+ int processedIndex = 0;
+ // avoid use tags if not available for all tokens
+ var tagsEnabled = tokenTags.length == tokens.length;
+ for (int i = 0; i < tokens.length; i++) {
+ String token = tokens[i];
+ // Tag is used here to allow optional matching by language POS tag
+ String tag = tagsEnabled ? tokenTags[i] : null;
+ if (processedIndex == tokensTemplate.length) {
+ return NLPTokenComparisonResult.ZERO;
+ }
+ String tokenTemplate = tokensTemplate[processedIndex];
+ var tokenAlternatives = splitString(tokenTemplate, "\\|");
+ boolean isMatch = false;
+ for (var tokenAlternative : tokenAlternatives) {
+ if (DYNAMIC_PLACEHOLDER_SYMBOL.equals(tokenAlternative)) {
+ if (tokenAlternatives.length > 1) {
+ logger.warn("Providing the dynamic placeholder as an optional token is not allowed");
+ return NLPTokenComparisonResult.ZERO;
+ }
+ if (tokensTemplate.length == 1) {
+ logger.warn("Providing the dynamic placeholder alone is not allowed");
+ return NLPTokenComparisonResult.ZERO;
+ }
+ if (processedIndex + 1 == tokensTemplate.length) {
+ // the dynamic placeholder is the last value in the template token array, returning score
+ // note that the dynamic placeholder does not count for score
+ return new NLPTokenComparisonResult(score, new Span(i, tokensTemplate.length));
+ }
+ // here we cut and reverse the arrays to run score backwards until the dynamic placeholder
+ var unprocessedTokens = Arrays.copyOfRange(tokens, i, tokens.length);
+ var unprocessedTags = tagsEnabled ? Arrays.copyOfRange(tokenTags, i, tokenTags.length)
+ : new String[] {};
+ var unprocessedTokensTemplate = Arrays.copyOfRange(tokensTemplate, processedIndex,
+ tokensTemplate.length);
+ Collections.reverse(Arrays.asList(unprocessedTokens));
+ Collections.reverse(Arrays.asList(unprocessedTags));
+ Collections.reverse(Arrays.asList(unprocessedTokensTemplate));
+ if (DYNAMIC_PLACEHOLDER_SYMBOL.equals(unprocessedTokens[0])) {
+ // here dynamic placeholder should be at the end, but if it's also at the beginning we should
+ // abort
+ logger.warn("Using multiple dynamic placeholders is not supported");
+ return NLPTokenComparisonResult.ZERO;
+ }
+ var partialScoreResult = compareTokens(unprocessedTokens, unprocessedTags,
+ unprocessedTokensTemplate);
+ if (NLPTokenComparisonResult.ZERO.equals(partialScoreResult)) {
+ return NLPTokenComparisonResult.ZERO;
+ } else {
+ var dynamicSpan = partialScoreResult.dynamicSpan;
+ if (dynamicSpan == null) {
+ logger.error(
+ "dynamic span missed, this should never happen, please open an issue; aborting");
+ return NLPTokenComparisonResult.ZERO;
+ }
+ return new NLPTokenComparisonResult(score + partialScoreResult.score,
+ new Span(i, tokens.length - (dynamicSpan.getStart())));
+ }
+ }
+ if (tokenAlternative.equals(token)) {
+ isMatch = true;
+ break;
+ }
+ }
+ if (isMatch) {
+ processedIndex++;
+ score++;
+ } else if (tag != null && optionalLanguageTags.contains(tag)) {
+ logger.debug("part '{}' tagged as '{}' skipped", token, tag);
+ } else {
+ return NLPTokenComparisonResult.ZERO;
+ }
+ }
+ return new NLPTokenComparisonResult(score, null);
+ }
+
+ private String[] splitString(String template, String regex) {
+ return Arrays.stream(template.split(regex)).map(String::trim).toArray(String[]::new);
+ }
+
+ private void invalidate() {
+ logger.debug("Invalidate cached item data");
+ nlpItemMaps = null;
+ }
+
+ private void reloadConfigs() {
+ optionalLanguageTags = Arrays.stream(this.config.optionalLanguageTags.split(",")).filter(i -> !i.isEmpty())
+ .collect(Collectors.toList());
+ tokenizer = getTokenizer();
+ }
+
+ private static class NLPInfo {
+ public final String[] tokens;
+ public final String[] lemmas;
+ public final String[] tags;
+
+ public NLPInfo(String[] tokens, String[] lemmas, String[] tags) {
+ this.tokens = tokens;
+ this.lemmas = lemmas;
+ this.tags = tags;
+ }
+ }
+
+ private static void createFolder(Logger logger, String nlpFolder) {
+ File directory = new File(nlpFolder);
+ if (!directory.exists()) {
+ if (directory.mkdir()) {
+ logger.debug("dir created {}", nlpFolder);
+ }
+ }
+ }
+
+ public static String getPlaceholderSymbol(String name) {
+ return "$" + name.replaceAll("\\s", "_");
+ }
+
+ private static class NLPInterpretationResult {
+ public final Item targetItem;
+ public final ActionTemplateConfiguration actionConfig;
+ public final Map placeholderValues;
+
+ public NLPInterpretationResult(Item targetItem, ActionTemplateConfiguration actionConfig,
+ Map placeholderValues) {
+ this.targetItem = targetItem;
+ this.actionConfig = actionConfig;
+ this.placeholderValues = placeholderValues;
+ }
+
+ public static NLPInterpretationResult from(Item item, ActionTemplateConfiguration actionConfig,
+ List placeholderValues) {
+ return new NLPInterpretationResult(item, actionConfig, placeholderValues.stream()
+ .collect(Collectors.toMap(i -> i.placeholderName, i -> i.placeholderValue)));
+ }
+ }
+
+ public static class NLPPlaceholderData {
+ private final String placeholderName;
+ private final String placeholderValue;
+ private final Span placeholderSpan;
+
+ private NLPPlaceholderData(String placeholderName, String placeholderValue, Span placeholderSpan) {
+ this.placeholderName = placeholderName;
+ this.placeholderValue = placeholderValue;
+ this.placeholderSpan = placeholderSpan;
+ }
+ }
+
+ private static class NLPTokenComparisonResult {
+ public static final NLPTokenComparisonResult ZERO = new NLPTokenComparisonResult(0, null);
+ public final int score;
+ public final @Nullable Span dynamicSpan;
+
+ private NLPTokenComparisonResult(int score, @Nullable Span dynamicSpan) {
+ this.score = score;
+ this.dynamicSpan = dynamicSpan;
+ }
+ }
+
+ private static class NLPItemMaps {
+ private final Map itemLabelByTokens;
+ private final Map itemsWithActionConfigs;
+
+ private NLPItemMaps(Map itemLabelByTokens,
+ Map itemsWithActionConfigs) {
+ this.itemLabelByTokens = itemLabelByTokens;
+ this.itemsWithActionConfigs = itemsWithActionConfigs;
+ }
+ }
+}
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterConfiguration.java b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterConfiguration.java
new file mode 100644
index 0000000000000..b818031c22080
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterConfiguration.java
@@ -0,0 +1,56 @@
+/**
+ * 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.voice.actiontemplatehli.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link ActionTemplateInterpreterConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+public class ActionTemplateInterpreterConfiguration {
+ /**
+ * Convert the input text to lower case before processing
+ */
+ public boolean lowerText = false;
+ /**
+ * Enable case sensitivity for pos and ner static values.
+ */
+ public boolean caseSensitive = false;
+ /**
+ * Message for successful command
+ */
+ public String commandSentMessage = "Done";
+ /**
+ * Message for unsuccessful processing
+ */
+ public String unhandledMessage = "I can not do that";
+ /**
+ * Message for error during processing
+ */
+ public String failureMessage = "There was an error";
+ /**
+ * POS tags that will be optional when comparing
+ */
+ public String optionalLanguageTags = "";
+ /**
+ * Prefer simple tokenizer over white space tokenizer
+ */
+ public boolean useSimpleTokenizer = false;
+ /**
+ * Enables build-in detokenization based on original text, otherwise string join by space is used
+ */
+ public boolean detokenizeOptimization = true;
+}
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterConstants.java b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterConstants.java
new file mode 100644
index 0000000000000..15ab169922883
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterConstants.java
@@ -0,0 +1,103 @@
+/**
+ * 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.voice.actiontemplatehli.internal;
+
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreter.getPlaceholderSymbol;
+
+import java.nio.file.Path;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.OpenHAB;
+
+/**
+ * The {@link ActionTemplateInterpreterConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+public class ActionTemplateInterpreterConstants {
+ /**
+ * Service name
+ */
+ public static final String SERVICE_NAME = "Action Template Interpreter";
+ /**
+ * Service id
+ */
+ public static final String SERVICE_ID = "actiontemplatehli";
+
+ /**
+ * Service category
+ */
+ public static final String SERVICE_CATEGORY = "voice";
+ /**
+ * Service pid
+ */
+ public static final String SERVICE_PID = "org.openhab." + SERVICE_CATEGORY + "." + SERVICE_ID;
+ /**
+ * Root service folder
+ */
+ public static final String NLP_FOLDER = Path.of(OpenHAB.getUserDataFolder(), "actiontemplatehli").toString();
+ /**
+ * NER folder for dictionaries and models
+ */
+ public static final String NER_FOLDER = Path.of(NLP_FOLDER, "ner").toString();
+ /**
+ * POS folder for dictionaries and models
+ */
+ public static final String POS_FOLDER = Path.of(NLP_FOLDER, "pos").toString();
+ /**
+ * Folder for type action configurations
+ */
+ public static final String TYPE_ACTION_CONFIGS_FOLDER = Path.of(NLP_FOLDER, "type_actions").toString();
+ /**
+ * ItemLabel placeholder name
+ */
+ public static final String ITEM_LABEL_PLACEHOLDER = "itemLabel";
+ /**
+ * ItemLabel placeholder symbol
+ */
+ public static final String ITEM_LABEL_PLACEHOLDER_SYMBOL = getPlaceholderSymbol(ITEM_LABEL_PLACEHOLDER);
+ /**
+ * State placeholder name
+ */
+ public static final String STATE_PLACEHOLDER = "state";
+ /**
+ * State placeholder symbol
+ */
+ public static final String STATE_PLACEHOLDER_SYMBOL = getPlaceholderSymbol(STATE_PLACEHOLDER);
+ /**
+ * Item option placeholder name
+ */
+ public static final String ITEM_OPTION_PLACEHOLDER = "itemOption";
+ /**
+ * State placeholder symbol
+ */
+ public static final String ITEM_OPTION_PLACEHOLDER_SYMBOL = getPlaceholderSymbol(ITEM_OPTION_PLACEHOLDER);
+ /**
+ * Dynamic placeholder name
+ */
+ public static final String DYNAMIC_PLACEHOLDER = "*";
+ /**
+ * Dynamic placeholder symbol
+ */
+ public static final String DYNAMIC_PLACEHOLDER_SYMBOL = getPlaceholderSymbol(DYNAMIC_PLACEHOLDER);
+ /**
+ * GroupLabel placeholder name
+ */
+ public static final String GROUP_LABEL_PLACEHOLDER = "groupLabel";
+ /**
+ * GroupLabel placeholder symbol
+ */
+ public static final String GROUP_LABEL_PLACEHOLDER_SYMBOL = getPlaceholderSymbol(GROUP_LABEL_PLACEHOLDER);
+}
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplateConfiguration.java b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplateConfiguration.java
new file mode 100644
index 0000000000000..5ddf65de81fe6
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplateConfiguration.java
@@ -0,0 +1,70 @@
+/**
+ * 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.voice.actiontemplatehli.internal.configuration;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.items.Metadata;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * The {@link ActionTemplateConfiguration} class represents each configured action
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+public class ActionTemplateConfiguration {
+ @JsonProperty("type")
+ public String type = "tokens";
+ @JsonProperty("read")
+ public boolean read = false;
+ @JsonProperty(value = "template", required = true)
+ public String template = "";
+ @JsonProperty("value")
+ public @Nullable Object value = null;
+ @JsonProperty("emptyValue")
+ public String emptyValue = "";
+ @JsonProperty("placeholders")
+ public List placeholders = List.of();
+ @JsonProperty("requiredTags")
+ public String[] requiredItemTags = new String[] {};
+ @JsonProperty("silent")
+ public boolean silent = false;
+ @JsonProperty("memberTargets")
+ public @Nullable ActionTemplateGroupTargets memberTargets = null;
+
+ public static ActionTemplateConfiguration[] fromMetadata(Metadata metadata) throws JsonProcessingException {
+ var configuration = metadata.getConfiguration();
+ var multipleValues = configuration.get("multiple");
+ ObjectMapper mapper = new ObjectMapper();
+ if (multipleValues != null) {
+ return mapper.readValue(mapper.writeValueAsString(multipleValues), ActionTemplateConfiguration[].class);
+ } else {
+ var actionConfig = mapper.readValue(mapper.writeValueAsString(configuration),
+ ActionTemplateConfiguration.class);
+ return new ActionTemplateConfiguration[] { actionConfig };
+ }
+ }
+
+ public static ActionTemplateConfiguration[] fromJSON(File jsonFile) throws IOException {
+ ObjectMapper mapper = new ObjectMapper();
+ return mapper.readValue(jsonFile, ActionTemplateConfiguration[].class);
+ }
+}
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplateGroupTargets.java b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplateGroupTargets.java
new file mode 100644
index 0000000000000..05097300ba1dc
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplateGroupTargets.java
@@ -0,0 +1,36 @@
+/**
+ * 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.voice.actiontemplatehli.internal.configuration;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * The {@link ActionTemplateGroupTargets} class filters the item targets when targeting an item group.
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+public class ActionTemplateGroupTargets {
+ @JsonProperty("itemName")
+ public String itemName = "";
+ @JsonProperty("itemType")
+ public String itemType = "";
+ @JsonProperty("requiredTags")
+ public String[] requiredItemTags = new String[] {};
+ @JsonProperty("mergeState")
+ public boolean mergeState = false;
+ @JsonProperty("recursive")
+ public boolean recursive = true;
+}
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplatePlaceholder.java b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplatePlaceholder.java
new file mode 100644
index 0000000000000..4f7aeb19f8644
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/java/org/openhab/voice/actiontemplatehli/internal/configuration/ActionTemplatePlaceholder.java
@@ -0,0 +1,45 @@
+/**
+ * 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.voice.actiontemplatehli.internal.configuration;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * The {@link ActionTemplatePlaceholder} class configures placeholders for the action template
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+public class ActionTemplatePlaceholder {
+ @JsonProperty(value = "label", required = true)
+ public String label = "";
+ @JsonProperty("ner")
+ public @Nullable String nerFile = null;
+ @JsonProperty("nerValues")
+ public String @Nullable [] nerStaticValues = null;
+ @JsonProperty("pos")
+ public @Nullable String posFile = null;
+ @JsonProperty("posValues")
+ public @Nullable Map posStaticValues = null;
+
+ public static ActionTemplatePlaceholder withLabel(String label) {
+ var placeholder = new ActionTemplatePlaceholder();
+ placeholder.label = label;
+ return placeholder;
+ }
+}
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.voice.actiontemplatehli/src/main/resources/OH-INF/config/config.xml
new file mode 100644
index 0000000000000..c9853ce3f6613
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/resources/OH-INF/config/config.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+ Configure natural language processing.
+
+
+
+ Configure interpreter responses.
+
+
+
+ Convert the input text to lowercase before processing.
+ false
+
+
+
+ Enable case sensitivity, do not apply to dictionaries and models, do not apply to the 'itemLabel'
+ placeholder.
+ false
+ true
+
+
+
+ Prefer simple tokenizer over white space tokenizer.
+ false
+ true
+
+
+
+ Enables build-in detokenization based on original text, otherwise string join by space is used.
+ true
+ true
+
+
+
+ Comma separated POS language tags that will be optional when comparing.
+
+
+
+ Message for successful command.
+ Done
+
+
+
+ Message for unsuccessful processing.
+ I can not do that
+
+
+
+ Message for error during processing.
+ There was an error
+
+
+
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/main/resources/OH-INF/i18n/actiontemplatehli.properties b/bundles/org.openhab.voice.actiontemplatehli/src/main/resources/OH-INF/i18n/actiontemplatehli.properties
new file mode 100644
index 0000000000000..8f43ea9a6105b
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/main/resources/OH-INF/i18n/actiontemplatehli.properties
@@ -0,0 +1,24 @@
+voice.config.actiontemplatehli.caseSensitive.label = Case Sensitive
+voice.config.actiontemplatehli.caseSensitive.description = Enable case sensitivity, do not apply to dictionaries and models, do not apply to the 'itemLabel' placeholder.
+voice.config.actiontemplatehli.commandSentMessage.label = Command Sent Message
+voice.config.actiontemplatehli.commandSentMessage.description = Message for successful command.
+voice.config.actiontemplatehli.detokenizeOptimization.label = Detokenize Optimization
+voice.config.actiontemplatehli.detokenizeOptimization.description = Enables build-in detokenization based on original text, otherwise string join by space is used.
+voice.config.actiontemplatehli.failureMessage.label = Failure Message
+voice.config.actiontemplatehli.failureMessage.description = Message for error during processing.
+voice.config.actiontemplatehli.group.messages.label = Response Messages
+voice.config.actiontemplatehli.group.messages.description = Configure interpreter responses.
+voice.config.actiontemplatehli.group.nlp.label = Neural Language Processing
+voice.config.actiontemplatehli.group.nlp.description = Configure natural language processing.
+voice.config.actiontemplatehli.lowerText.label = Lower Text
+voice.config.actiontemplatehli.lowerText.description = Convert the input text to lowercase before processing.
+voice.config.actiontemplatehli.optionalLanguageTags.label = Optional Language Tags
+voice.config.actiontemplatehli.optionalLanguageTags.description = Comma separated POS language tags that will be optional when comparing.
+voice.config.actiontemplatehli.unhandledMessage.label = Unhandled Message
+voice.config.actiontemplatehli.unhandledMessage.description = Message for unsuccessful processing.
+voice.config.actiontemplatehli.useSimpleTokenizer.label = Use Simple Tokenizer
+voice.config.actiontemplatehli.useSimpleTokenizer.description = Prefer simple tokenizer over white space tokenizer.
+
+# service
+
+service.voice.actiontemplatehli.label = Action Template Interpreter
diff --git a/bundles/org.openhab.voice.actiontemplatehli/src/test/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterTest.java b/bundles/org.openhab.voice.actiontemplatehli/src/test/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterTest.java
new file mode 100644
index 0000000000000..ae2fab49ac404
--- /dev/null
+++ b/bundles/org.openhab.voice.actiontemplatehli/src/test/java/org/openhab/voice/actiontemplatehli/internal/ActionTemplateInterpreterTest.java
@@ -0,0 +1,248 @@
+/**
+ * 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.voice.actiontemplatehli.internal;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.openhab.voice.actiontemplatehli.internal.ActionTemplateInterpreterConstants.SERVICE_ID;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.items.GroupItem;
+import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.items.Metadata;
+import org.openhab.core.items.MetadataKey;
+import org.openhab.core.items.MetadataRegistry;
+import org.openhab.core.items.events.ItemEventFactory;
+import org.openhab.core.library.items.NumberItem;
+import org.openhab.core.library.items.StringItem;
+import org.openhab.core.library.items.SwitchItem;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.types.StateDescriptionFragmentBuilder;
+import org.openhab.core.types.StateOption;
+import org.openhab.core.voice.text.InterpretationException;
+import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplateConfiguration;
+import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplateGroupTargets;
+import org.openhab.voice.actiontemplatehli.internal.configuration.ActionTemplatePlaceholder;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+/**
+ * The {@link ActionTemplateInterpreterTest} class contains the tests for the interpreter
+ *
+ * @author Miguel Álvarez - Initial contribution
+ */
+@NonNullByDefault
+public class ActionTemplateInterpreterTest {
+ private @Mock @NonNullByDefault({}) ItemRegistry itemRegistryMock;
+ private @Mock @NonNullByDefault({}) MetadataRegistry metadataRegistryMock;
+ private @Mock @NonNullByDefault({}) EventPublisher eventPublisherMock;
+ private @NonNullByDefault({}) ActionTemplateInterpreter interpreter;
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ MockitoAnnotations.openMocks(this);
+ ObjectMapper mapper = new ObjectMapper();
+ // Prepare Switch
+ var switchItem = new SwitchItem("testSwitch");
+ switchItem.setState(OnOffType.OFF);
+ switchItem.setLabel("bedroom light");
+ switchItem.addTag("Light");
+ Mockito.when(itemRegistryMock.get(switchItem.getName())).thenReturn(switchItem);
+ // Prepare Switch Write action
+ var switchNPLWriteAction = new ActionTemplateConfiguration();
+ switchNPLWriteAction.template = "$onOff $itemLabel";
+ switchNPLWriteAction.value = "$onOff";
+ var onOffPlaceholder = new ActionTemplatePlaceholder();
+ onOffPlaceholder.label = "onOff";
+ onOffPlaceholder.nerStaticValues = new String[] { "turn on", "turn off" };
+ onOffPlaceholder.posStaticValues = Map.of("turn__on", "ON", "turn__off", "OFF");
+ switchNPLWriteAction.placeholders = List.of(onOffPlaceholder);
+ // Prepare Switch Read action
+ var switchNPLReadAction = new ActionTemplateConfiguration();
+ switchNPLReadAction.read = true;
+ switchNPLReadAction.template = "how is the $itemLabel";
+ switchNPLReadAction.value = "$itemLabel is $state";
+ // Prepare Group
+ var groupItem = new GroupItem("testGroup");
+ groupItem.setLabel("bedroom");
+ groupItem.addTag("Location");
+ Mockito.when(itemRegistryMock.get(groupItem.getName())).thenReturn(groupItem);
+ // TV channel
+ var numberItem = new NumberItem("testNumber");
+ numberItem.setState(DecimalType.valueOf("1"));
+ numberItem.setLabel("channel");
+ numberItem.addTag("tv_channel");
+ numberItem
+ .setStateDescriptionService((text, locale) -> StateDescriptionFragmentBuilder
+ .create().withOptions(List.of(new StateOption("1", "channel one"),
+ new StateOption("2", "channel two"), new StateOption("3", "channel three")))
+ .build().toStateDescription());
+ Mockito.when(itemRegistryMock.get(numberItem.getName())).thenReturn(numberItem);
+ // Prepare Group Write action
+ var groupNPLWriteAction = new ActionTemplateConfiguration();
+ groupNPLWriteAction.template = "turn on $itemLabel lights";
+ groupNPLWriteAction.requiredItemTags = new String[] { "Location" };
+ groupNPLWriteAction.value = "ON";
+ groupNPLWriteAction.memberTargets = new ActionTemplateGroupTargets();
+ groupNPLWriteAction.memberTargets.itemType = "Switch";
+ groupNPLWriteAction.memberTargets.requiredItemTags = new String[] { "Light" };
+ // Prepare Group Read action
+ var groupNPLReadAction = new ActionTemplateConfiguration();
+ groupNPLReadAction.read = true;
+ groupNPLReadAction.requiredItemTags = new String[] { "Location" };
+ groupNPLReadAction.template = "how is the light in the $itemLabel";
+ groupNPLReadAction.value = "$itemLabel in $groupLabel is $state";
+ var statePlaceholder = new ActionTemplatePlaceholder();
+ statePlaceholder.label = "state";
+ statePlaceholder.posStaticValues = Map.of("ON", "on", "OFF", "off");
+ groupNPLReadAction.placeholders = List.of(statePlaceholder);
+ groupNPLReadAction.memberTargets = new ActionTemplateGroupTargets();
+ groupNPLReadAction.memberTargets.itemName = switchItem.getName();
+ groupNPLReadAction.memberTargets.requiredItemTags = new String[] { "Light" };
+ // Prepare group write action using item option
+ var groupNPLOptionWriteAction = new ActionTemplateConfiguration();
+ groupNPLOptionWriteAction.template = "set $itemLabel channel to $itemOption";
+ groupNPLOptionWriteAction.requiredItemTags = new String[] { "Location" };
+ groupNPLOptionWriteAction.value = "$itemOption";
+ groupNPLOptionWriteAction.memberTargets = new ActionTemplateGroupTargets();
+ groupNPLOptionWriteAction.memberTargets.itemType = "Number";
+ groupNPLOptionWriteAction.memberTargets.requiredItemTags = new String[] { "tv_channel" };
+ // Prepare group read action using item option
+ var groupNPLOptionReadAction = new ActionTemplateConfiguration();
+ groupNPLOptionReadAction.read = true;
+ groupNPLOptionReadAction.requiredItemTags = new String[] { "Location" };
+ groupNPLOptionReadAction.template = "what channel is on the $itemLabel tv";
+ groupNPLOptionReadAction.value = "$groupLabel tv is on $itemOption";
+ groupNPLOptionReadAction.memberTargets = new ActionTemplateGroupTargets();
+ groupNPLOptionReadAction.memberTargets.itemType = "Number";
+ groupNPLOptionReadAction.memberTargets.requiredItemTags = new String[] { "tv_channel" };
+ // Add switch member to group
+ groupItem.addMember(switchItem);
+ // Add number member to group
+ groupItem.addMember(numberItem);
+ // Prepare string
+ var stringItem = new StringItem("testString");
+ stringItem.setLabel("message example");
+ Mockito.when(itemRegistryMock.get(stringItem.getName())).thenReturn(stringItem);
+ // Prepare string write action
+ var stringNPLWriteAction = new ActionTemplateConfiguration();
+ stringNPLWriteAction.template = "send message $* to $contact";
+ stringNPLWriteAction.value = "$contact:$*";
+ stringNPLWriteAction.silent = true;
+ var contactPlaceholder = new ActionTemplatePlaceholder();
+ contactPlaceholder.label = "contact";
+ contactPlaceholder.nerStaticValues = new String[] { "Mark", "Andrea" };
+ contactPlaceholder.posStaticValues = Map.of("Mark", "+34000000000", "Andrea", "+34000000001");
+ stringNPLWriteAction.placeholders = List.of(contactPlaceholder);
+ var stringConfig = mapper.readValue(mapper.writeValueAsString(stringNPLWriteAction), Map.class);
+ // Mock metadata for 'testString'
+ Mockito.when(metadataRegistryMock.get(new MetadataKey(SERVICE_ID, stringItem.getName())))
+ .thenReturn(new Metadata(new MetadataKey(SERVICE_ID, stringItem.getName()), "", stringConfig));
+ // Mock items
+ Mockito.when(itemRegistryMock.getAll()).thenReturn(List.of(switchItem, stringItem, groupItem, numberItem));
+
+ interpreter = new ActionTemplateInterpreter(itemRegistryMock, metadataRegistryMock, eventPublisherMock) {
+ @Override
+ protected ActionTemplateConfiguration[] getTypeActionConfigs(String itemType) {
+ // mock type actions for testing
+ if ("Switch".equals(itemType)) {
+ return new ActionTemplateConfiguration[] { switchNPLWriteAction, switchNPLReadAction };
+ }
+ if ("Group".equals(itemType)) {
+ return new ActionTemplateConfiguration[] { groupNPLWriteAction, groupNPLReadAction,
+ groupNPLOptionReadAction, groupNPLOptionWriteAction };
+ }
+ return new ActionTemplateConfiguration[] {};
+ }
+ };
+ }
+
+ /**
+ * Test type write action
+ */
+ @Test
+ public void switchItemOnOffTest() throws InterpretationException {
+ var response = interpreter.interpret(Locale.ENGLISH, "turn on bedroom light");
+ assertThat(response, is("Done"));
+ Mockito.verify(eventPublisherMock).post(ItemEventFactory.createCommandEvent("testSwitch", OnOffType.ON));
+ response = interpreter.interpret(Locale.ENGLISH, "turn off bedroom light");
+ assertThat(response, is("Done"));
+ Mockito.verify(eventPublisherMock).post(ItemEventFactory.createCommandEvent("testSwitch", OnOffType.OFF));
+ }
+
+ /**
+ * Test type read action
+ */
+ @Test
+ public void switchItemReadTest() throws InterpretationException {
+ var response = interpreter.interpret(Locale.ENGLISH, "how is the bedroom light");
+ assertThat(response, is("bedroom light is OFF"));
+ }
+
+ /**
+ * Test group write action targeting members
+ */
+ @Test
+ public void groupItemMemberOnTest() throws InterpretationException {
+ var response = interpreter.interpret(Locale.ENGLISH, "turn on bedroom lights");
+ assertThat(response, is("Done"));
+ Mockito.verify(eventPublisherMock).post(ItemEventFactory.createCommandEvent("testSwitch", OnOffType.ON));
+ }
+
+ /**
+ * Test group read action targeting members
+ */
+ @Test
+ public void groupItemMemberReadTest() throws InterpretationException {
+ var response = interpreter.interpret(Locale.ENGLISH, "how is the light in the bedroom");
+ assertThat(response, is("bedroom light in bedroom is off"));
+ }
+
+ /**
+ * Test target a group member item using the itemOption placeholder
+ */
+ @Test
+ public void groupItemOptionTest() throws InterpretationException {
+ var response = interpreter.interpret(Locale.ENGLISH, "what channel is on the bedroom tv");
+ assertThat(response, is("bedroom tv is on channel one"));
+ response = interpreter.interpret(Locale.ENGLISH, "set bedroom channel to channel two");
+ assertThat(response, is("Done"));
+ Mockito.verify(eventPublisherMock)
+ .post(ItemEventFactory.createCommandEvent("testNumber", new DecimalType("2")));
+ }
+
+ /**
+ * Test write action using the dynamic label
+ */
+ @Test
+ public void messageTest() throws InterpretationException {
+ var response = interpreter.interpret(Locale.ENGLISH, "send message please turn off the bedroom light to mark");
+ // silent mode is enabled so no response
+ assertThat(response, is(""));
+ Mockito.verify(eventPublisherMock).post(ItemEventFactory.createCommandEvent("testString",
+ new StringType("+34000000000:please turn off the bedroom light")));
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index e1ab1accb9c8d..e9617d68becd3 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -397,6 +397,7 @@
org.openhab.voice.googlettsorg.openhab.voice.macttsorg.openhab.voice.marytts
+ org.openhab.voice.actiontemplatehliorg.openhab.voice.picottsorg.openhab.voice.pollyttsorg.openhab.voice.porcupineks