From e4ce954dc2b27dadae6f03dfdf974c5a32ed744c Mon Sep 17 00:00:00 2001 From: jimtng <2554958+jimtng@users.noreply.github.com> Date: Thu, 29 Aug 2024 00:28:10 +1000 Subject: [PATCH] [basicprofiles] Add additional comparisons to State Filter profile (#17323) * Add inequality comparisons to State Filter profile - Fix bug where undefined `mismatchState` passed `UNDEF` instead of ignoring state updates - Support multiline conditions - Support comparing against the input state from handler to filter out unwanted data * Support comparing item to item or input to item Signed-off-by: Jimmy Tanagra --- .../README.md | 117 +++-- .../config/StateFilterProfileConfig.java | 8 +- .../factory/BasicProfilesFactory.java | 2 +- .../internal/profiles/StateFilterProfile.java | 325 +++++++++---- .../resources/OH-INF/config/state-filter.xml | 31 +- .../OH-INF/i18n/basicprofiles.properties | 33 +- .../profiles/StateFilterProfileTest.java | 442 +++++++++++++++++- 7 files changed, 806 insertions(+), 152 deletions(-) diff --git a/bundles/org.openhab.transform.basicprofiles/README.md b/bundles/org.openhab.transform.basicprofiles/README.md index e934a1fefb7cc..d0acca3b92ade 100644 --- a/bundles/org.openhab.transform.basicprofiles/README.md +++ b/bundles/org.openhab.transform.basicprofiles/README.md @@ -7,14 +7,14 @@ This bundle provides a list of useful Profiles. This Profile can be used to send a Command towards the Item when one event of a specified event list is triggered. The given Command value is parsed either to `IncreaseDecreaseType`, `NextPreviousType`, `OnOffType`, `PlayPauseType`, `RewindFastforwardType`, `StopMoveType`, `UpDownType` or a `StringType` is used. -### Configuration +### Generic Command Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|------|----------------------------------------------------------------------------------| | `events` | text | Comma separated list of events to which the profile should listen. **mandatory** | | `command` | text | Command which should be sent if the event is triggered. **mandatory** | -### Full Example +### Generic Command Profile Example ```java Switch lightsStatus { @@ -27,13 +27,13 @@ Switch lightsStatus { The Generic Toggle Switch Profile is a specialization of the Generic Command Profile and toggles the State of a Switch Item whenever one of the specified events is triggered. -### Configuration +### Generic Toggle Switch Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|------|----------------------------------------------------------------------------------| | `events` | text | Comma separated list of events to which the profile should listen. **mandatory** | -### Full Example +### Generic Toggle Switch Profile Example ```java Switch lightsStatus { @@ -47,13 +47,13 @@ Switch lightsStatus { This Profile counts and skips a user-defined number of State changes before it sends an update to the Item. It can be used to debounce Item States. -### Configuration +### Debounce (Counting) Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|---------|-----------------------------------------------| | `numberOfChanges` | integer | Number of changes before updating Item State. | -### Full Example +### Debounce (Counting) Profile Example ```java Switch debouncedSwitch { channel="xxx" [profile="basic-profiles:debounce-counting", numberOfChanges=2] } @@ -66,7 +66,7 @@ In `FIRST` mode this profile discards values for the configured time after a val It can be used to debounce Item States/Commands or prevent excessive load on networks. -### Configuration +### Debounce (Time) Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|---------|-----------------------------------------------| @@ -74,7 +74,7 @@ It can be used to debounce Item States/Commands or prevent excessive load on net | `toHandlerDelay` | integer | Timespan in ms before a received command is passed to the handler. | | `mode` | text | `FIRST` (sends the first value received and discards later values), `LAST` (sends the last value received, discarding earlier values). | -### Full Example +### Debounce (Time) Profile Example ```java Number:Temperature debouncedSetpoint { channel="xxx" [profile="basic-profiles:debounce-time", toHandlerDelay=1000] } @@ -87,7 +87,7 @@ It requires no specific configuration. The values of `QuantityType`, `PercentType` and `DecimalTypes` are negated (multiplied by `-1`). Otherwise the following mapping is used: - + `IncreaseDecreaseType`: `INCREASE` <-> `DECREASE` `NextPreviousType`: `NEXT` <-> `PREVIOUS` `OnOffType`: `ON` <-> `OFF` @@ -97,7 +97,7 @@ Otherwise the following mapping is used: `StopMoveType`: `MOVE` <-> `STOP` `UpDownType`: `UP` <-> `DOWN` -### Full Example +### Invert / Negate Profile Example ```java Switch invertedSwitch { channel="xxx" [profile="basic-profiles:invert"] } @@ -109,14 +109,14 @@ The Round Profile scales the State to a specific number of decimal places based Optionally the [Rounding mode](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/math/RoundingMode.html) can be set. Source Channels should accept Item Type `Number`. -### Configuration +### Round Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|---------|-----------------------------------------------------------------------------------------------------------------| | `scale` | integer | Scale to indicate the resulting number of decimal places (min: -16, max: 16, STEP: 1) **mandatory**. | | `mode` | text | Rounding mode to be used (e.g. "UP", "DOWN", "CEILING", "FLOOR", "HALF_UP" or "HALF_DOWN" (default: "HALF_UP"). | -### Full Example +### Round Profile Example ```java Number roundedNumber { channel="xxx" [profile="basic-profiles:round", scale=0] } @@ -133,13 +133,13 @@ Source Channels should accept Item Type `Dimmer` or `Number`. This profile is a shortcut for the System Hysteresis Profile. ::: -### Configuration +### Threshold Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|---------|-----------------------------------------------------------------------------------------------------| | `threshold` | integer | Triggers `ON` if value is below the given threshold, otherwise OFF (default: 10, min: 0, max: 100). | -### Full Example +### Threshold Profile Example ```java Switch thresholdItem { channel="xxx" [profile="basic-profiles:threshold", threshold=15] } @@ -152,7 +152,7 @@ The value of the percent type can be different between a specific time of the da A possible use-case is switching lights (using a presence detector) with different intensities at day and at night. Be aware: a range beyond midnight (e.g. start="23:00", end="01:00") is not yet supported. -### Configuration +### Time Range Profile Configuration | Configuration Parameter | Type | Description | |-------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------| @@ -169,7 +169,7 @@ Possible values for parameter `restoreValue`: - `PREVIOUS` - Return to previous value - `0` - `100` - Set a user-defined percent value -### Full Example +### Time Range Profile Example ```java Switch motionSensorFirstFloor { @@ -180,31 +180,80 @@ Switch motionSensorFirstFloor { ## State Filter Profile -This filter passes on state updates from a (binding) handler to the item if and only if all listed item state conditions -are met (conditions are ANDed together). -Option to instead pass different state update in case the conditions are not met. -State values may be quoted to treat as `StringType`. +This filter passes on state updates from the (binding) handler to the item if and only if all listed conditions are met (conditions are ANDed together). +In case the conditions are not met, a fixed predefined state can be passed to the item instead of ignoring the update. + +Use cases: + +- Ignore values from the binding unless some other item(s) have a specific state. +- Filter out invalid values from the binding. + +### State Filter Configuration + +| Configuration Parameter | Type | Description | +| ----------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `conditions` | text | A list of conditions to check before posting an update from the binding to the item. When all the conditions are met, the update from the binding is passed to the item. | +| `mismatchState` | text | What to pass to the item when `conditions` aren't met. Use single quotes to treat as `StringType`. When undefined (the default), updates from the binding are ignored. | +| `separator` | text | Optional separator string to separate multiple expressions. Defaults to `,`. | + +#### State Filter Conditions -Use case: Ignore values from a binding unless some other item(s) have a specific state. +The conditions are defined in the format `[ITEM_NAME] OPERATOR VALUE_OR_ITEM_NAME`, e.g. `MyItem EQ OFF`. +Multiple conditions can be entered on separate lines in the UI, or in a single line separated with the `separator` character/string. -### Configuration +The state of one item can be compared against the state of another item by having item names on both sides of the comparison, e.g.: `Item1 > Item2`. +When `ITEM_NAME` is omitted, e.g. `> 10, < 100`, the comparisons are applied against the input data from the binding. +In this case, the value can also be replaced with an item name, which will result in comparing the input state against the state of that item, e.g. `> LowerLimitItem, < UpperLimitItem`. +This can be used to filter out unwanted data, e.g. to ensure that incoming data are within a reasonable range. -| Configuration Parameter | Type | Description | -|-------------------------|------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `conditions` | text | Comma separated list of expressions on the format `ITEM_NAME OPERATOR ITEM_STATE`, ie `MyItem EQ OFF`. Use quotes around `ITEM_STATE` to treat value as string ie `'OFF'` and not `OnOffType.OFF` | -| `mismatchState` | text | Optional state to pass instead if conditions are NOT met. Use single quotes to treat as `StringType`. Defaults to `UNDEF` | -| `separator` | text | Optional separator string to separate expressions when using multiple. Defaults to `,` | +Some tips: -Possible values for token `OPERATOR` in `conditions`: +- When dealing with QuantityType data, the unit must be included in the comparison value, e.g.: `PowerItem > 1 kW`. +- Use single quotes around the `VALUE` to perform a string comparison, e.g. `'UNDEF'` is not equal to `UNDEF` (of type `UnDefType`). + This will distinguish between a string literal and an item name or a constant such as `UNDEF`, `ON`/`OFF`, `OPEN`, etc. +- `VALUE` cannot be on the left hand side of the operator. -- `EQ` - Equals -- `NEQ` - Not equals +##### State Filter Operators +| Name | Symbol | | +| :---: | :----------: | ------------------------- | +| `EQ` | `==` | Equals | +| `NEQ` | `!=` or `<>` | Not equals | +| `GT` | `>` | Greater than | +| `GTE` | `>=` | Greater than or equals to | +| `LT` | `<` | Less than | +| `LTE` | `<=` | Less than or equals to | -### Full Example +Notes: + +- The operator names must be surrounded by spaces, i.e.: `Item EQ 10` +- The operator symbols do not need to be surrounded by spaces, e.g.: `Item==10` and `Item == 10` are both fine. + +### State Filter Examples + +Condition based on the state of other items: + +```java +Number:Temperature airconTemperature { + channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions="airconPower_item EQ ON", mismatchState="UNDEF" ] +} +``` + +Check against the incoming state, to discard incoming data outside a fixed range: + +```java +Number:Power PowerUsage { + channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions=">= 0 kW", "< 20 kW" ] +} +``` + +The incoming state can be compared against other items: + +```java +Number:Power MinimumPowerLimit { unit="W" } +Number:Power MaximumPowerLimit { unit="W" } -```Java -Number:Temperature airconTemperature{ - channel="mybinding:mything:mychannel"[profile="basic-profiles:state-filter",conditions="airconPower_item EQ ON",mismatchState="UNDEF"] +Number:Power PowerUsage { + channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions=">= MinimumPowerLimit", "< MaximumPowerLimit" ] } ``` diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java index 89429374bd4a8..e6311ef74f366 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java +++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/config/StateFilterProfileConfig.java @@ -12,8 +12,10 @@ */ package org.openhab.transform.basicprofiles.internal.config; +import java.util.List; + import org.eclipse.jdt.annotation.NonNullByDefault; -import org.openhab.core.types.UnDefType; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.transform.basicprofiles.internal.profiles.StateFilterProfile; /** @@ -24,9 +26,9 @@ @NonNullByDefault public class StateFilterProfileConfig { - public String conditions = ""; + public List conditions = List.of(); - public String mismatchState = UnDefType.UNDEF.toString(); + public @Nullable String mismatchState; public String separator = ","; } diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java index 8f1da9adedbc6..54d1b62e04b53 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java +++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/factory/BasicProfilesFactory.java @@ -106,7 +106,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider .withSupportedChannelTypeUIDs(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_MOTION) // .build(); private static final ProfileType PROFILE_STATE_FILTER = ProfileTypeBuilder - .newState(STATE_FILTER_UID, "Filter handler state updates based on any item state").build(); + .newState(STATE_FILTER_UID, "State Filter").build(); private static final Set SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GENERIC_COMMAND_UID, GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, INVERT_UID, ROUND_UID, THRESHOLD_UID, diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java index 7e0c8f9947217..e48236a070453 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java +++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java @@ -12,17 +12,26 @@ */ package org.openhab.transform.basicprofiles.internal.profiles; +import static java.util.function.Predicate.not; import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +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.items.Item; import org.openhab.core.items.ItemNotFoundException; import org.openhab.core.items.ItemRegistry; +import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.StringType; import org.openhab.core.thing.profiles.ProfileCallback; import org.openhab.core.thing.profiles.ProfileContext; @@ -31,6 +40,7 @@ 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.transform.basicprofiles.internal.config.StateFilterProfileConfig; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,59 +50,91 @@ * met. * * @author Arne Seime - Initial contribution + * @author Jimmy Tanagra - Expanded the comparison types */ @NonNullByDefault public class StateFilterProfile implements StateProfile { + private final static String OPERATOR_NAME_PATTERN = Stream.of(StateCondition.ComparisonType.values()) + .map(StateCondition.ComparisonType::name) + // We want to match the longest operator first, e.g. `GTE` before `GT` + .sorted(Comparator.comparingInt(String::length).reversed()) + // Require a leading space only when it is preceded by a non-space character, e.g. `Item1 GTE 0` + // so we can have conditions against input data without needing a leading space, e.g. `GTE 0` + .collect(Collectors.joining("|", "(?:(?<=\\S)\\s+|^\\s*)(?:", ")\\s")); + + private final static String OPERATOR_SYMBOL_PATTERN = Stream.of(StateCondition.ComparisonType.values()) + .map(StateCondition.ComparisonType::symbol) + // We want to match the longest operator first, e.g. `<=` before `<` + .sorted(Comparator.comparingInt(String::length).reversed()) // + .collect(Collectors.joining("|", "(?:", ")")); + + private final static Pattern EXPRESSION_PATTERN = Pattern.compile( + // - Without the non-greedy operator in the first capture group, + // it will match `Item<` when encountering `Item<>X` condition + // - Symbols may be more prevalently used, so check them first + "(.*?)(" + OPERATOR_SYMBOL_PATTERN + "|" + OPERATOR_NAME_PATTERN + ")(.*)", Pattern.CASE_INSENSITIVE); + private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class); - private final ItemRegistry itemRegistry; private final ProfileCallback callback; - private List> acceptedDataTypes; - private List conditions = List.of(); + private final ItemRegistry itemRegistry; + + private final List conditions; - private @Nullable State configMismatchState = null; + private final @Nullable State configMismatchState; public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) { this.callback = callback; - acceptedDataTypes = context.getAcceptedDataTypes(); this.itemRegistry = itemRegistry; StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class); if (config != null) { conditions = parseConditions(config.conditions, config.separator); - configMismatchState = parseState(config.mismatchState); + if (conditions.isEmpty()) { + logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}", + callback.getItemChannelLink(), config.conditions); + } + configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes()); + } else { + conditions = List.of(); + configMismatchState = null; } } - private List parseConditions(@Nullable String config, String separator) { - if (config == null) { - return List.of(); - } - + private List parseConditions(List conditions, String separator) { List parsedConditions = new ArrayList<>(); - try { - String[] expressions = config.split(separator); - for (String expression : expressions) { - String[] parts = expression.trim().split("\s"); - if (parts.length == 3) { - String itemName = parts[0]; - StateCondition.ComparisonType conditionType = StateCondition.ComparisonType - .valueOf(parts[1].toUpperCase(Locale.ROOT)); - String value = parts[2]; - parsedConditions.add(new StateCondition(itemName, conditionType, value)); - } else { - logger.warn("Malformed condition expression: '{}'", expression); - } - } - return parsedConditions; - } catch (IllegalArgumentException e) { - logger.warn("Cannot parse condition {}. Expected format ITEM_NAME STATE_VALUE: '{}'", config, - e.getMessage()); - return List.of(); - } + conditions.stream() // + .flatMap(c -> Stream.of(c.split(separator))) // + .map(String::trim) // + .filter(not(String::isBlank)) // + .forEach(expression -> { + Matcher matcher = EXPRESSION_PATTERN.matcher(expression); + if (!matcher.matches()) { + logger.warn( + "Malformed condition expression: '{}' in link '{}'. Expected format ITEM_NAME OPERATOR ITEM_OR_STATE, where OPERATOR is one of: {}", + expression, callback.getItemChannelLink(), + StateCondition.ComparisonType.namesAndSymbols()); + return; + } + + String itemName = matcher.group(1).trim(); + String operator = matcher.group(2).trim(); + String value = matcher.group(3).trim(); + try { + StateCondition.ComparisonType comparisonType = StateCondition.ComparisonType + .fromSymbol(operator).orElseGet( + () -> StateCondition.ComparisonType.valueOf(operator.toUpperCase(Locale.ROOT))); + parsedConditions.add(new StateCondition(itemName, comparisonType, value)); + } catch (IllegalArgumentException e) { + logger.warn("Invalid comparison operator: '{}' in link '{}'. Expected one of: {}", operator, + callback.getItemChannelLink(), StateCondition.ComparisonType.namesAndSymbols()); + } + }); + + return parsedConditions; } @Override @@ -128,40 +170,24 @@ public void onStateUpdateFromHandler(State state) { @Nullable private State checkCondition(State state) { - if (!conditions.isEmpty()) { - boolean allConditionsMet = true; - for (StateCondition condition : conditions) { - logger.debug("Evaluting condition: {}", condition); - try { - Item item = itemRegistry.getItem(condition.itemName); - String itemState = item.getState().toString(); - - if (!condition.matches(itemState)) { - allConditionsMet = false; - } - } catch (ItemNotFoundException e) { - logger.warn( - "Cannot find item '{}' in registry - check your condition expression - skipping state update", - condition.itemName); - allConditionsMet = false; - } - - } - if (allConditionsMet) { - return state; - } else { - return configMismatchState; - } - } else { + if (conditions.isEmpty()) { logger.warn( - "No configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update"); + "No valid configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update. Link: '{}'", + callback.getItemChannelLink()); + return null; } - return null; + String linkedItemName = callback.getItemChannelLink().getItemName(); + + if (conditions.stream().allMatch(c -> c.check(linkedItemName, state))) { + return state; + } else { + return configMismatchState; + } } @Nullable - State parseState(@Nullable String stateString) { + static State parseState(@Nullable String stateString, List> acceptedDataTypes) { // Quoted strings are parsed as StringType if (stateString == null) { return null; @@ -173,47 +199,180 @@ State parseState(@Nullable String stateString) { } class StateCondition { - String itemName; - - ComparisonType comparisonType; - String value; - - boolean quoted = false; + private String itemName; + private ComparisonType comparisonType; + private String value; + private @Nullable State parsedValue; public StateCondition(String itemName, ComparisonType comparisonType, String value) { this.itemName = itemName; this.comparisonType = comparisonType; this.value = value; - this.quoted = value.startsWith("'") && value.endsWith("'"); - if (quoted) { - this.value = value.substring(1, value.length() - 1); - } + // Convert quoted strings to StringType, and UnDefTypes to UnDefType + // UnDefType gets special treatment because we don't want `UNDEF` to be parsed as a string + // Anything else, defer parsing until we're checking the condition + // so we can try based on the item's accepted data types + this.parsedValue = parseState(value, List.of(UnDefType.class)); } - public boolean matches(String state) { - switch (comparisonType) { - case EQ: - return state.equals(value); - case NEQ: { - return !state.equals(value); + /** + * Check if the condition is met. + * + * If the itemName is not empty, the condition is checked against the item's state. + * Otherwise, the condition is checked against the input state. + * + * @param input the state to check against + * @return true if the condition is met, false otherwise + */ + public boolean check(String linkedItemName, State input) { + try { + State state; + Item item = null; + + if (logger.isDebugEnabled()) { + logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input, + input.getClass().getSimpleName(), callback.getItemChannelLink()); + } + if (itemName.isEmpty()) { + item = itemRegistry.getItem(linkedItemName); + state = input; + } else { + item = itemRegistry.getItem(itemName); + state = item.getState(); + } + + // Using Object because we could be comparing State or String objects + Object lhs; + Object rhs; + + // Java Enums (e.g. OnOffType) are Comparable, but we want to treat them as not Comparable + if (state instanceof Comparable && !(state instanceof Enum)) { + lhs = state; + } else { + // Only allow EQ and NEQ for non-comparable states + if (!(comparisonType == ComparisonType.EQ || comparisonType == ComparisonType.NEQ + || comparisonType == ComparisonType.NEQ_ALT)) { + logger.debug("Condition state: '{}' ({}) only supports '==' and '!==' comparisons", state, + state.getClass().getSimpleName()); + return false; + } + lhs = state instanceof Enum ? state : state.toString(); + } + + if (parsedValue == null) { + // don't parse bare strings as StringType, because they are identifiers, + // e.g. referring to other items + List> acceptedValueTypes = item.getAcceptedDataTypes().stream() + .filter(not(StringType.class::isAssignableFrom)).toList(); + parsedValue = TypeParser.parseState(acceptedValueTypes, value); + // Don't convert QuantityType to other types, so that 1500 != 1500 W + if (parsedValue != null && !(parsedValue instanceof QuantityType)) { + // Try to convert it to the same type as the state + // This allows comparing compatible types, e.g. PercentType vs OnOffType + parsedValue = parsedValue.as(state.getClass()); + } + + // If the values can't be converted to a type, check to see if it's an Item name + if (parsedValue == null) { + try { + Item valueItem = itemRegistry.getItem(value); + if (valueItem != null) { // ItemRegistry.getItem can return null in tests + parsedValue = valueItem.getState(); + // Don't convert QuantityType to other types + if (!(parsedValue instanceof QuantityType)) { + parsedValue = parsedValue.as(state.getClass()); + } + logger.debug("Condition value: '{}' is an item state: '{}' ({})", value, parsedValue, + parsedValue == null ? "null" : parsedValue.getClass().getSimpleName()); + } + } catch (ItemNotFoundException ignore) { + } + } + + if (parsedValue == null) { + if (comparisonType == ComparisonType.NEQ || comparisonType == ComparisonType.NEQ_ALT) { + // They're not even type compatible, so return true for NEQ comparison + return true; + } else { + logger.debug("Condition value: '{}' is not compatible with state '{}' ({})", value, state, + state.getClass().getSimpleName()); + return false; + } + } + } + + rhs = Objects.requireNonNull(parsedValue instanceof StringType ? parsedValue.toString() : parsedValue); + + if (logger.isDebugEnabled()) { + if (itemName.isEmpty()) { + logger.debug("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs, + lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName()); + } else { + logger.debug("Performing a comparison between item '{}' state '{}' ({}) and value '{}' ({})", + itemName, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName()); + } } - default: - logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update", - comparisonType); - return false; + return switch (comparisonType) { + case EQ -> lhs.equals(rhs); + case NEQ, NEQ_ALT -> !lhs.equals(rhs); + case GT -> ((Comparable) lhs).compareTo(rhs) > 0; + case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0; + case LT -> ((Comparable) lhs).compareTo(rhs) < 0; + case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0; + }; + } catch (ItemNotFoundException | IllegalArgumentException | ClassCastException e) { + logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(), + e.getMessage()); } + return false; } enum ComparisonType { - EQ, - NEQ + EQ("=="), + NEQ("!="), + NEQ_ALT("<>"), + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String symbol; + + ComparisonType(String symbol) { + this.symbol = symbol; + } + + String symbol() { + return symbol; + } + + static Optional fromSymbol(String symbol) { + for (ComparisonType type : values()) { + if (type.symbol.equals(symbol)) { + return Optional.of(type); + } + } + return Optional.empty(); + } + + static List namesAndSymbols() { + return Stream.of(values()).flatMap(entry -> Stream.of(entry.name(), entry.symbol())).toList(); + } } @Override public String toString() { - return "Condition{itemName='" + itemName + "', comparisonType=" + comparisonType + ", value='" + value - + "'}'"; + Object state = null; + + try { + state = itemRegistry.getItem(itemName).getState(); + } catch (ItemNotFoundException ignored) { + } + + String stateClass = state == null ? "null" : state.getClass().getSimpleName(); + return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType=" + + comparisonType + ", value='" + value + "')"; } } } diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml index c468dcd9fdd1a..d809007b5997a 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml +++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml @@ -5,14 +5,37 @@ xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd"> - + - Comma separated list of expressions on the format ITEM_NAME OPERATOR ITEM_STATE, ie "MyItem EQ OFF". Use - quotes around ITEM_STATE to treat value as string ie "'OFF'". +
+ Multiple conditions can be specified by writing each expression on a separate line, or + when specified in the same line, separated by the separator character (default: ","). + All the conditions are ANDed to determine the result. +

+ The following operators are supported: + EQ or ==, + NE, !=, or <>, + GT or >, + GTE or >=, + LT or <, and + LTE or <=. + ]]>
- State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType` + State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`. If + not + defined, the state update will not be passed to the item when conditions are not met. + + + true + + The character/string used to separate multiple conditions in a single line. Defaults to ",". + ,
diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties index 0b287c6c11cdb..018c12a8d7d5b 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties +++ b/bundles/org.openhab.transform.basicprofiles/src/main/resources/OH-INF/i18n/basicprofiles.properties @@ -1,17 +1,13 @@ -profile-type.basic-profiles.generic-command.label = Generic Command -profile.config.basic-profiles.generic-command.events.label = Events -profile.config.basic-profiles.generic-command.events.description = Comma separated list of events to which the profile should listen. -profile.config.basic-profiles.generic-command.command.label = Command -profile.config.basic-profiles.generic-command.command.description = Command which should be sent if the event is triggered. +# add-on -profile-type.basic-profiles.toggle-switch.label = Generic Toggle Switch -profile.config.basic-profiles.toggle-switch.events.label = Events -profile.config.basic-profiles.toggle-switch.events.description = Comma separated list of events to which the profile should listen. +addon.basicprofiles.name = Basic Profiles +addon.basicprofiles.description = A set of profiles with basic functionality. + +# add-on profile-type.basic-profiles.debounce-counting.label = Debounce (Counting) profile.config.basic-profiles.debounce-counting.numberOfChanges.label = Number Of Changes profile.config.basic-profiles.debounce-counting.numberOfChanges.description = Number of changes before updating Item State. - profile-type.basic-profiles.debounce-time.label = Debounce (Time) profile.config.basic-profiles.debounce-time.toItemDelay.label = To Item Delay profile.config.basic-profiles.debounce-time.toItemDelay.description = Milliseconds before updating Item State. @@ -20,19 +16,20 @@ profile.config.basic-profiles.debounce-time.toHandlerDelay.description = Millise profile.config.basic-profiles.debounce-time.mode.label = Mode profile.config.basic-profiles.debounce-time.mode.option.FIRST = Send first value profile.config.basic-profiles.debounce-time.mode.option.LAST = Send last value - +profile-type.basic-profiles.generic-command.label = Generic Command +profile.config.basic-profiles.generic-command.events.label = Events +profile.config.basic-profiles.generic-command.events.description = Comma separated list of events to which the profile should listen. +profile.config.basic-profiles.generic-command.command.label = Command +profile.config.basic-profiles.generic-command.command.description = Command which should be sent if the event is triggered. profile-type.basic-profiles.invert.label = Invert / Negate - profile-type.basic-profiles.round.label = Round profile.config.basic-profiles.round.scale.label = Scale profile.config.basic-profiles.round.scale.description = Scale to indicate the resulting number of decimal places. profile.config.basic-profiles.round.mode.label = Rounding Mode profile.config.basic-profiles.round.mode.description = Rounding mode to be used (UP, DOWN, CEILING, FLOOR, HALF_UP or HALF_DOWN). - profile-type.basic-profiles.threshold.label = Threshold profile.config.basic-profiles.threshold.threshold.label = Threshold profile.config.basic-profiles.threshold.threshold.description = Triggers ON if value is below the given threshold, otherwise OFF. - profile-type.basic-profiles.time-range-command.label = Time Range Command profile.config.basic-profiles.time-range-command.inRangeValue.label = In Range Value profile.config.basic-profiles.time-range-command.inRangeValue.description = The value which will be send when the profile detects ON and current time is between start time and end time. @@ -47,3 +44,13 @@ profile.config.basic-profiles.time-range-command.restoreValue.description = Sele profile.config.basic-profiles.time-range-command.restoreValue.option.OFF = Off profile.config.basic-profiles.time-range-command.restoreValue.option.PREVIOUS = Return to previous value profile.config.basic-profiles.time-range-command.restoreValue.option.NOTHING = Do nothing +profile-type.basic-profiles.toggle-switch.label = Generic Toggle Switch +profile.config.basic-profiles.toggle-switch.events.label = Events +profile.config.basic-profiles.toggle-switch.events.description = Comma separated list of events to which the profile should listen. +profile-type.basic-profiles.state-filter.label = State Filter +profile.config.basic-profiles.state-filter.conditions.label = Conditions +profile.config.basic-profiles.state-filter.conditions.description = List of expressions in the format [ITEM_NAME] OPERATOR VALUE_OR_ITEM_NAME, e.g. "MyItem == OFF". Use quotes around VALUE_OR_ITEM_NAME to perform string comparison e.g. "'OFF'". VALUE can be a DecimalType or a QuantityType with a unit. When ITEM_NAME is omitted, the comparisons are done against the input state from the channel.

Multiple conditions can be specified by writing each expression on a separate line, or when specified in the same line, separated by the separator character (default: ","). All the conditions are ANDed to determine the result.

The following operators are supported: EQ or ==, NE, !=, or <>, GT or >, GTE or >=, LT or <, and LTE or <=. +profile.config.basic-profiles.state-filter.mismatchState.label = State for filter rejects +profile.config.basic-profiles.state-filter.mismatchState.description = State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`. If not defined, the state update will not be passed to the item when conditions are not met. +profile.config.basic-profiles.state-filter.separator.label = Expression Separator +profile.config.basic-profiles.state-filter.separator.description = The character/string used to separate multiple conditions in a single line. Defaults to ",". diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java index bb16fa7c0f865..425a23273e7d8 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java +++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java @@ -12,36 +12,51 @@ */ package org.openhab.transform.basicprofiles.internal.profiles; +import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.Hashtable; import java.util.List; import java.util.Map; +import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.openhab.core.config.core.Configuration; +import org.openhab.core.i18n.UnitProvider; +import org.openhab.core.internal.i18n.I18nProviderImpl; +import org.openhab.core.items.GenericItem; import org.openhab.core.items.Item; import org.openhab.core.items.ItemNotFoundException; import org.openhab.core.items.ItemRegistry; -import org.openhab.core.library.items.StringItem; -import org.openhab.core.library.types.OnOffType; -import org.openhab.core.library.types.StringType; +import org.openhab.core.library.items.*; +import org.openhab.core.library.types.*; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.thing.link.ItemChannelLink; import org.openhab.core.thing.profiles.ProfileCallback; import org.openhab.core.thing.profiles.ProfileContext; import org.openhab.core.types.State; import org.openhab.core.types.UnDefType; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.ComponentContext; /** * Basic unit tests for {@link StateFilterProfile}. @@ -56,11 +71,27 @@ public class StateFilterProfileTest { private @Mock @NonNullByDefault({}) ProfileCallback mockCallback; private @Mock @NonNullByDefault({}) ProfileContext mockContext; private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry; + private @Mock @NonNullByDefault({}) ItemChannelLink mockItemChannelLink; + + private static final UnitProvider UNIT_PROVIDER; + + static { + ComponentContext context = Mockito.mock(ComponentContext.class); + BundleContext bundleContext = Mockito.mock(BundleContext.class); + Hashtable properties = new Hashtable<>(); + properties.put("measurementSystem", SIUnits.MEASUREMENT_SYSTEM_NAME); + when(context.getProperties()).thenReturn(properties); + when(context.getBundleContext()).thenReturn(bundleContext); + UNIT_PROVIDER = new I18nProviderImpl(context); + } @BeforeEach - public void setup() { + public void setup() throws ItemNotFoundException { reset(mockContext); reset(mockCallback); + reset(mockItemChannelLink); + when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink); + when(mockItemRegistry.getItem("")).thenThrow(ItemNotFoundException.class); } @Test @@ -85,9 +116,9 @@ public void testMalformedConditions() { @Test public void testInvalidComparatorConditions() throws ItemNotFoundException { - when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName lt Value"))); + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName is Value"))); StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); - when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class); + when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value")); State expectation = OnOffType.ON; profile.onStateUpdateFromHandler(expectation); @@ -119,7 +150,7 @@ public void testInvalidMultipleConditions() throws ItemNotFoundException { @Test public void testSingleConditionMatch() throws ItemNotFoundException { - when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value"))); + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq 'Value'"))); when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value")); StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); @@ -147,10 +178,16 @@ private Item stringItemWithState(String itemName, String value) { return item; } + private Item numberItemWithState(String itemType, String itemName, State value) { + NumberItem item = new NumberItem(itemType, itemName, null); + item.setState(value); + return item; + } + @Test public void testMultipleCondition_AllMatch() throws ItemNotFoundException { when(mockContext.getConfiguration()) - .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2"))); + .thenReturn(new Configuration(Map.of("conditions", "ItemName eq 'Value', ItemName2 eq 'Value2'"))); when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value")); when(mockItemRegistry.getItem("ItemName2")).thenReturn(stringItemWithState("ItemName2", "Value2")); @@ -203,14 +240,391 @@ public void testFailingConditionWithMismatchStateQuoted() throws ItemNotFoundExc @Test void testParseStateNonQuotes() { - when(mockContext.getAcceptedDataTypes()) - .thenReturn(List.of(UnDefType.class, OnOffType.class, StringType.class)); + List> acceptedDataTypes = List.of(UnDefType.class, OnOffType.class, StringType.class); + + when(mockContext.getAcceptedDataTypes()).thenReturn(acceptedDataTypes); when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", ""))); StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); - assertEquals(UnDefType.UNDEF, profile.parseState("UNDEF")); - assertEquals(new StringType("UNDEF"), profile.parseState("'UNDEF'")); - assertEquals(OnOffType.ON, profile.parseState("ON")); - assertEquals(new StringType("ON"), profile.parseState("'ON'")); + assertEquals(UnDefType.UNDEF, profile.parseState("UNDEF", acceptedDataTypes)); + assertEquals(new StringType("UNDEF"), profile.parseState("'UNDEF'", acceptedDataTypes)); + assertEquals(OnOffType.ON, profile.parseState("ON", acceptedDataTypes)); + assertEquals(new StringType("ON"), profile.parseState("'ON'", acceptedDataTypes)); + } + + public static Stream testComparingItemWithValue() { + NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER); + NumberItem decimalItem = new NumberItem("ItemName"); + StringItem stringItem = new StringItem("ItemName"); + SwitchItem switchItem = new SwitchItem("ItemName"); + DimmerItem dimmerItem = new DimmerItem("ItemName"); + ContactItem contactItem = new ContactItem("ItemName"); + RollershutterItem rollershutterItem = new RollershutterItem("ItemName"); + + QuantityType q_1500W = QuantityType.valueOf("1500 W"); + DecimalType d_1500 = DecimalType.valueOf("1500"); + StringType s_foo = StringType.valueOf("foo"); + StringType s_NULL = StringType.valueOf("NULL"); + StringType s_UNDEF = StringType.valueOf("UNDEF"); + StringType s_OPEN = StringType.valueOf("OPEN"); + + return Stream.of( // + // We should be able to check item state is/isn't UNDEF/NULL + + // First, when the item state is actually an UnDefType + // An unquoted value UNDEF/NULL should be treated as an UnDefType + // Only equality comparisons against the matching UnDefType will return true + // Any other comparisons should return false + Arguments.of(stringItem, UnDefType.UNDEF, "==", "UNDEF", true), // + Arguments.of(dimmerItem, UnDefType.UNDEF, "==", "UNDEF", true), // + Arguments.of(dimmerItem, UnDefType.NULL, "==", "NULL", true), // + Arguments.of(dimmerItem, UnDefType.NULL, "==", "UNDEF", false), // + Arguments.of(decimalItem, UnDefType.NULL, ">", "10", false), // + Arguments.of(decimalItem, UnDefType.NULL, "<", "10", false), // + Arguments.of(decimalItem, UnDefType.NULL, "==", "10", false), // + Arguments.of(decimalItem, UnDefType.NULL, ">=", "10", false), // + Arguments.of(decimalItem, UnDefType.NULL, "<=", "10", false), // + + // A quoted value (String) isn't UnDefType + Arguments.of(stringItem, UnDefType.UNDEF, "==", "'UNDEF'", false), // + Arguments.of(stringItem, UnDefType.UNDEF, "!=", "'UNDEF'", true), // + Arguments.of(stringItem, UnDefType.NULL, "==", "'NULL'", false), // + Arguments.of(stringItem, UnDefType.NULL, "!=", "'NULL'", true), // + + // When the item state is not an UnDefType + // UnDefType is special. When unquoted and comparing against a StringItem, + // don't treat it as a string + Arguments.of(stringItem, s_NULL, "==", "'NULL'", true), // Comparing String to String + Arguments.of(stringItem, s_NULL, "==", "NULL", false), // String state != UnDefType + Arguments.of(stringItem, s_NULL, "!=", "NULL", true), // + Arguments.of(stringItem, s_UNDEF, "==", "'UNDEF'", true), // Comparing String to String + Arguments.of(stringItem, s_UNDEF, "==", "UNDEF", false), // String state != UnDefType + Arguments.of(stringItem, s_UNDEF, "!=", "UNDEF", true), // + + Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "UNDEF", false), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "UNDEF", true), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "NULL", false), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "NULL", true), // + + // Check for OPEN/CLOSED + Arguments.of(contactItem, OpenClosedType.OPEN, "==", "OPEN", true), // + Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'OPEN'", true), // String != Enum + Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "CLOSED", true), // + Arguments.of(contactItem, OpenClosedType.OPEN, "==", "CLOSED", false), // + Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "CLOSED", true), // + Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "OPEN", true), // + + // ON/OFF + Arguments.of(switchItem, OnOffType.ON, "==", "ON", true), // + Arguments.of(switchItem, OnOffType.ON, "!=", "ON", false), // + Arguments.of(switchItem, OnOffType.ON, "!=", "OFF", true), // + Arguments.of(switchItem, OnOffType.ON, "!=", "UNDEF", true), // + Arguments.of(switchItem, UnDefType.UNDEF, "==", "UNDEF", true), // + Arguments.of(switchItem, OnOffType.ON, "==", "'ON'", false), // it's not a string + Arguments.of(switchItem, OnOffType.ON, "!=", "'ON'", true), // incompatible types + + // Enum types != String + Arguments.of(contactItem, OpenClosedType.OPEN, "==", "'OPEN'", false), // + Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'CLOSED'", true), // + Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'OPEN'", true), // + Arguments.of(contactItem, OpenClosedType.OPEN, "==", "'CLOSED'", false), // + Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "'CLOSED'", false), // + Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "'CLOSED'", true), // + + // non UnDefType checks + // String constants must be quoted + Arguments.of(stringItem, s_foo, "==", "'foo'", true), // + Arguments.of(stringItem, s_foo, "==", "foo", false), // + Arguments.of(stringItem, s_foo, "!=", "foo", true), // not quoted -> not a string + Arguments.of(stringItem, s_foo, "<>", "foo", true), // + Arguments.of(stringItem, s_foo, " <>", "foo", true), // + Arguments.of(stringItem, s_foo, "<> ", "foo", true), // + Arguments.of(stringItem, s_foo, " <> ", "foo", true), // + Arguments.of(stringItem, s_foo, "!=", "'foo'", false), // + Arguments.of(stringItem, s_foo, "<>", "'foo'", false), // + Arguments.of(stringItem, s_foo, " <>", "'foo'", false), // + + Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "100", true), // + Arguments.of(dimmerItem, PercentType.HUNDRED, ">=", "100", true), // + Arguments.of(dimmerItem, PercentType.HUNDRED, ">", "50", true), // + Arguments.of(dimmerItem, PercentType.HUNDRED, ">=", "50", true), // + Arguments.of(dimmerItem, PercentType.ZERO, "<", "50", true), // + Arguments.of(dimmerItem, PercentType.ZERO, ">=", "50", false), // + Arguments.of(dimmerItem, PercentType.ZERO, ">=", "0", true), // + Arguments.of(dimmerItem, PercentType.ZERO, "<", "0", false), // + Arguments.of(dimmerItem, PercentType.ZERO, "<=", "0", true), // + + // Numeric vs Strings aren't comparable + Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "'100'", false), // + Arguments.of(rollershutterItem, PercentType.HUNDRED, "!=", "'100'", true), // + Arguments.of(rollershutterItem, PercentType.HUNDRED, ">", "'10'", false), // + Arguments.of(powerItem, q_1500W, "==", "'1500 W'", false), // QuantityType vs String => fail + Arguments.of(decimalItem, d_1500, "==", "'1500'", false), // + + // Compatible type castings are supported + Arguments.of(dimmerItem, PercentType.ZERO, "==", "OFF", true), // + Arguments.of(dimmerItem, PercentType.ZERO, "==", "ON", false), // + Arguments.of(dimmerItem, PercentType.ZERO, "!=", "ON", true), // + Arguments.of(dimmerItem, PercentType.ZERO, "!=", "OFF", false), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "ON", true), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "OFF", false), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "ON", false), // + Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "OFF", true), // + + // UpDownType gets converted to PercentType for comparison + Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "DOWN", true), // + Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "UP", false), // + Arguments.of(rollershutterItem, PercentType.HUNDRED, "!=", "UP", true), // + Arguments.of(rollershutterItem, PercentType.ZERO, "==", "UP", true), // + Arguments.of(rollershutterItem, PercentType.ZERO, "!=", "DOWN", true), // + + Arguments.of(decimalItem, d_1500, " eq ", "1500", true), // + Arguments.of(decimalItem, d_1500, " eq ", "1500", true), // + Arguments.of(decimalItem, d_1500, "==", "1500", true), // + Arguments.of(decimalItem, d_1500, " ==", "1500", true), // + Arguments.of(decimalItem, d_1500, "== ", "1500", true), // + Arguments.of(decimalItem, d_1500, " == ", "1500", true), // + + Arguments.of(powerItem, q_1500W, " eq ", "1500", false), // no unit => fail + Arguments.of(powerItem, q_1500W, "==", "1500", false), // no unit => fail + Arguments.of(powerItem, q_1500W, " eq ", "1500 cm", false), // wrong unit + Arguments.of(powerItem, q_1500W, "==", "1500 cm", false), // wrong unit + + Arguments.of(powerItem, q_1500W, " eq ", "1500 W", true), // + Arguments.of(powerItem, q_1500W, " eq ", "1.5 kW", true), // + Arguments.of(powerItem, q_1500W, " eq ", "2 kW", false), // + Arguments.of(powerItem, q_1500W, "==", "1500 W", true), // + Arguments.of(powerItem, q_1500W, "==", "1.5 kW", true), // + Arguments.of(powerItem, q_1500W, "==", "2 kW", false), // + + Arguments.of(powerItem, q_1500W, " neq ", "500 W", true), // + Arguments.of(powerItem, q_1500W, " neq ", "1500", true), // Not the same type, so not equal + Arguments.of(powerItem, q_1500W, " neq ", "1500 W", false), // + Arguments.of(powerItem, q_1500W, " neq ", "1.5 kW", false), // + Arguments.of(powerItem, q_1500W, "!=", "500 W", true), // + Arguments.of(powerItem, q_1500W, "!=", "1500", true), // not the same type + Arguments.of(powerItem, q_1500W, "!=", "1500 W", false), // + Arguments.of(powerItem, q_1500W, "!=", "1.5 kW", false), // + + Arguments.of(powerItem, q_1500W, " GT ", "100 W", true), // + Arguments.of(powerItem, q_1500W, " GT ", "1 kW", true), // + Arguments.of(powerItem, q_1500W, " GT ", "2 kW", false), // + Arguments.of(powerItem, q_1500W, ">", "100 W", true), // + Arguments.of(powerItem, q_1500W, ">", "1 kW", true), // + Arguments.of(powerItem, q_1500W, ">", "2 kW", false), // + Arguments.of(powerItem, q_1500W, " GTE ", "1500 W", true), // + Arguments.of(powerItem, q_1500W, " GTE ", "1 kW", true), // + Arguments.of(powerItem, q_1500W, " GTE ", "1.5 kW", true), // + Arguments.of(powerItem, q_1500W, " GTE ", "2 kW", false), // + Arguments.of(powerItem, q_1500W, " GTE ", "2000 mW", true), // + Arguments.of(powerItem, q_1500W, " GTE ", "20", false), // no unit + Arguments.of(powerItem, q_1500W, ">=", "1.5 kW", true), // + Arguments.of(powerItem, q_1500W, ">=", "2 kW", false), // + Arguments.of(powerItem, q_1500W, " LT ", "2 kW", true), // + Arguments.of(powerItem, q_1500W, "<", "2 kW", true), // + Arguments.of(powerItem, q_1500W, " LTE ", "2 kW", true), // + Arguments.of(powerItem, q_1500W, "<=", "2 kW", true), // + Arguments.of(powerItem, q_1500W, "<=", "1 kW", false), // + Arguments.of(powerItem, q_1500W, " LTE ", "1.5 kW", true), // + Arguments.of(powerItem, q_1500W, "<=", "1.5 kW", true) // + ); + } + + @ParameterizedTest + @MethodSource + public void testComparingItemWithValue(GenericItem item, State state, String operator, String value, + boolean expected) throws ItemNotFoundException { + String itemName = item.getName(); + item.setState(state); + + when(mockContext.getConfiguration()) + .thenReturn(new Configuration(Map.of("conditions", itemName + operator + value))); + when(mockItemRegistry.getItem(itemName)).thenReturn(item); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + State inputData = new StringType("NewValue"); + profile.onStateUpdateFromHandler(inputData); + verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputData)); + } + + public static Stream testComparingItemWithOtherItem() { + NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER); + NumberItem powerItem2 = new NumberItem("Number:Power", "powerItem2", UNIT_PROVIDER); + NumberItem decimalItem = new NumberItem("decimalItem"); + NumberItem decimalItem2 = new NumberItem("decimalItem2"); + StringItem stringItem = new StringItem("stringItem"); + StringItem stringItem2 = new StringItem("stringItem2"); + ContactItem contactItem = new ContactItem("contactItem"); + ContactItem contactItem2 = new ContactItem("contactItem2"); + + QuantityType q_1500W = QuantityType.valueOf("1500 W"); + QuantityType q_1_5kW = QuantityType.valueOf("1.5 kW"); + QuantityType q_10kW = QuantityType.valueOf("10 kW"); + + DecimalType d_1500 = DecimalType.valueOf("1500"); + DecimalType d_2000 = DecimalType.valueOf("2000"); + StringType s_1500 = StringType.valueOf("1500"); + StringType s_foo = StringType.valueOf("foo"); + StringType s_NULL = StringType.valueOf("NULL"); + + return Stream.of( // + Arguments.of(stringItem, s_foo, "==", stringItem2, s_foo, true), // + Arguments.of(stringItem, s_foo, "!=", stringItem2, s_foo, false), // + Arguments.of(stringItem, s_foo, "==", stringItem2, s_NULL, false), // + Arguments.of(stringItem, s_foo, "!=", stringItem2, s_NULL, true), // + + Arguments.of(decimalItem, d_1500, "==", decimalItem2, d_1500, true), // + Arguments.of(decimalItem, d_1500, "==", decimalItem2, d_1500, true), // + + // UNDEF/NULL are equals regardless of item type + Arguments.of(decimalItem, UnDefType.UNDEF, "==", stringItem, UnDefType.UNDEF, true), // + Arguments.of(decimalItem, UnDefType.NULL, "==", stringItem, UnDefType.NULL, true), // + Arguments.of(decimalItem, UnDefType.NULL, "==", stringItem, UnDefType.UNDEF, false), // + + Arguments.of(contactItem, OpenClosedType.OPEN, "==", contactItem2, OpenClosedType.OPEN, true), // + Arguments.of(contactItem, OpenClosedType.OPEN, "==", contactItem2, OpenClosedType.CLOSED, false), // + + Arguments.of(decimalItem, d_1500, "==", decimalItem2, d_1500, true), // + Arguments.of(decimalItem, d_1500, "<", decimalItem2, d_2000, true), // + Arguments.of(decimalItem, d_1500, ">", decimalItem2, d_2000, false), // + Arguments.of(decimalItem, d_1500, ">", stringItem, s_1500, false), // + Arguments.of(powerItem, q_1500W, "<", powerItem2, q_10kW, true), // + Arguments.of(powerItem, q_1500W, ">", powerItem2, q_10kW, false), // + Arguments.of(powerItem, q_1500W, "==", powerItem2, q_1_5kW, true), // + Arguments.of(powerItem, q_1500W, ">=", powerItem2, q_1_5kW, true), // + Arguments.of(powerItem, q_1500W, ">", powerItem2, q_1_5kW, false), // + + // Incompatible types + Arguments.of(decimalItem, d_1500, "==", stringItem, s_1500, false), // + Arguments.of(powerItem, q_1500W, "==", decimalItem, d_1500, false), // DecimalType != QuantityType + Arguments.of(decimalItem, d_1500, "==", powerItem, q_1500W, false) // + ); + } + + @ParameterizedTest + @MethodSource + public void testComparingItemWithOtherItem(GenericItem item, State state, String operator, GenericItem item2, + State state2, boolean expected) throws ItemNotFoundException { + String itemName = item.getName(); + item.setState(state); + + String itemName2 = item2.getName(); + item2.setState(state2); + + if (item.equals(item2)) { + // For test writers: + // When using the same items, it doesn't make sense for their states to be different + assertEquals(state, state2); + } + + when(mockContext.getConfiguration()) + .thenReturn(new Configuration(Map.of("conditions", itemName + operator + itemName2))); + when(mockItemRegistry.getItem(itemName)).thenReturn(item); + when(mockItemRegistry.getItem(itemName2)).thenReturn(item2); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + State inputData = new StringType("NewValue"); + profile.onStateUpdateFromHandler(inputData); + verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputData)); + } + + public static Stream testComparingInputStateWithValue() { + NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER); + NumberItem decimalItem = new NumberItem("ItemName"); + StringItem stringItem = new StringItem("ItemName"); + DimmerItem dimmerItem = new DimmerItem("ItemName"); + + QuantityType q_1500W = QuantityType.valueOf("1500 W"); + DecimalType d_1500 = DecimalType.valueOf("1500"); + StringType s_foo = StringType.valueOf("foo"); + + return Stream.of( // + // We should be able to check that input state is/isn't UNDEF/NULL + + // First, when the input state is actually an UnDefType + // An unquoted value UNDEF/NULL should be treated as an UnDefType + Arguments.of(stringItem, UnDefType.UNDEF, "==", "UNDEF", true), // + Arguments.of(dimmerItem, UnDefType.NULL, "==", "NULL", true), // + Arguments.of(dimmerItem, UnDefType.NULL, "==", "UNDEF", false), // + + // A quoted value (String) isn't UnDefType + Arguments.of(stringItem, UnDefType.UNDEF, "==", "'UNDEF'", false), // + Arguments.of(stringItem, UnDefType.UNDEF, "!=", "'UNDEF'", true), // + Arguments.of(stringItem, UnDefType.NULL, "==", "'NULL'", false), // + Arguments.of(stringItem, UnDefType.NULL, "!=", "'NULL'", true), // + + // String values must be quoted + Arguments.of(stringItem, s_foo, "==", "'foo'", true), // + Arguments.of(stringItem, s_foo, "!=", "'foo'", false), // + Arguments.of(stringItem, s_foo, "==", "'bar'", false), // + // Unquoted string values are not compatible + // always returns false + Arguments.of(stringItem, s_foo, "==", "foo", false), // + Arguments.of(stringItem, s_foo, "!=", "foo", true), // not quoted -> not equal to string + + Arguments.of(decimalItem, d_1500, "==", "1500", true), // + Arguments.of(decimalItem, d_1500, "!=", "1500", false), // + Arguments.of(decimalItem, d_1500, "==", "1000", false), // + Arguments.of(decimalItem, d_1500, "!=", "1000", true), // + Arguments.of(decimalItem, d_1500, ">", "1000", true), // + Arguments.of(decimalItem, d_1500, ">=", "1000", true), // + Arguments.of(decimalItem, d_1500, ">=", "1500", true), // + Arguments.of(decimalItem, d_1500, "<", "1600", true), // + Arguments.of(decimalItem, d_1500, "<=", "1600", true), // + Arguments.of(decimalItem, d_1500, "<", "1000", false), // + Arguments.of(decimalItem, d_1500, "<=", "1000", false), // + Arguments.of(decimalItem, d_1500, "<", "1500", false), // + Arguments.of(decimalItem, d_1500, "<=", "1500", true), // + + // named operators - must have a trailing space + Arguments.of(decimalItem, d_1500, "LT ", "2000", true), // + Arguments.of(decimalItem, d_1500, "LTE ", "1500", true), // + Arguments.of(decimalItem, d_1500, " LTE ", "1500", true), // + Arguments.of(decimalItem, d_1500, " LTE ", "1500", true), // + + Arguments.of(powerItem, q_1500W, "==", "1500 W", true), // + Arguments.of(powerItem, q_1500W, "==", "'1500 W'", false), // QuantityType != String + Arguments.of(powerItem, q_1500W, "==", "1.5 kW", true), // + Arguments.of(powerItem, q_1500W, ">", "2000 mW", true) // + ); + } + + @ParameterizedTest + @MethodSource + public void testComparingInputStateWithValue(GenericItem linkedItem, State inputState, String operator, + String value, boolean expected) throws ItemNotFoundException { + + String linkedItemName = linkedItem.getName(); + + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", operator + value))); + when(mockItemRegistry.getItem(linkedItemName)).thenReturn(linkedItem); + when(mockItemChannelLink.getItemName()).thenReturn(linkedItemName); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + profile.onStateUpdateFromHandler(inputState); + verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputState)); + } + + @ParameterizedTest + @MethodSource("testComparingItemWithOtherItem") + public void testComparingInputStateWithItem(GenericItem linkedItem, State inputState, String operator, + GenericItem item, State state, boolean expected) throws ItemNotFoundException { + String linkedItemName = linkedItem.getName(); + + String itemName = item.getName(); + item.setState(state); + + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", operator + itemName))); + when(mockItemRegistry.getItem(itemName)).thenReturn(item); + when(mockItemRegistry.getItem(linkedItemName)).thenReturn(linkedItem); + when(mockItemChannelLink.getItemName()).thenReturn(linkedItemName); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + profile.onStateUpdateFromHandler(inputState); + verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputState)); } }