diff --git a/bundles/org.openhab.transform.basicprofiles/README.md b/bundles/org.openhab.transform.basicprofiles/README.md index e934a1fefb7cc..6814b2e3913ef 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,66 @@ 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 + +The conditions are defined in the format `[ITEM_NAME] OPERATOR VALUE`, 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. + +When `ITEM_NAME` is omitted, e.g. `> 10, < 100`, the comparison is applied against the input data from the binding. +This can be used to filter out unwanted data, e.g. to ensure that incoming data are within a reasonable range. -Use case: Ignore values from a binding unless some other item(s) have a specific state. +Some tips: -### Configuration +- 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 `UnDefType.UNDEF`. +- When comparing against StringType data (e.g. a `StringItem`), the value does not need to be single-quoted. -| 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 `,` | +##### State Filter Operators -Possible values for token `OPERATOR` in `conditions`: -- `EQ` - Equals -- `NEQ` - Not equals +| Name | Symbol | | +| :---: | :----: | ------------------------- | +| `EQ` | `==` | Equals | +| `NEQ` | `!=` | Not equals | +| `GT` | `>` | Greater than | +| `GTE` | `>=` | Greater than or equals to | +| `LT` | `<` | Less than | +| `LTE` | `<=` | Less than or equals to | +Notes: -### Full Example +- 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. +- Only symbolic operators can be used when comparing against the incoming state, e.g. `> 10`. Using operator names isn't supported, i.e. this is not supported: ~~`GT 10`~~. -```Java -Number:Temperature airconTemperature{ - channel="mybinding:mything:mychannel"[profile="basic-profiles:state-filter",conditions="airconPower_item EQ ON",mismatchState="UNDEF"] +### State Filter Example + +```java +Number:Temperature airconTemperature { + channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions="airconPower_item EQ ON", mismatchState="UNDEF" ] +} +``` + +With multiple conditions, make sure incoming data is between 0 kW and 20 kW, discarding values outside this range: + +```java +Number:Power PowerUsage { + channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions=">= 0 kW", "< 20 kW" ] } ``` 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..a68b981dd08d8 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,11 +12,17 @@ */ 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.List; import java.util.Locale; +import java.util.Objects; +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; @@ -31,6 +37,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 +47,75 @@ * met. * * @author Arne Seime - Initial contribution + * @author Jimmy Tanagra - Expanded the comparison types */ @NonNullByDefault public class StateFilterProfile implements StateProfile { 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 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); + conditions = parseConditions(config.conditions, config.separator, itemRegistry); + 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, ItemRegistry itemRegistry) { 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(); - } + String operatorPattern = Stream.of(StateCondition.ComparisonType.values()) + .flatMap(type -> Stream.of(" " + type.name() + " ", type.getSymbol())) + .sorted((a, b) -> Integer.compare(b.length(), a.length())) // We want to match the longest operator + // first, e.g. `<=` before `<` + .collect(Collectors.joining("|")); + + Pattern expressionPattern = Pattern.compile("(.*)(" + operatorPattern + ")(.*)", Pattern.CASE_INSENSITIVE); + + conditions.stream() // + .flatMap(c -> Stream.of(c.split(separator))) // + .map(String::trim) // + .filter(not(String::isBlank)) // + .forEach(expression -> { + Matcher matcher = expressionPattern.matcher(expression); + if (!matcher.matches()) { + logger.warn( + "Malformed condition expression: '{}'. Expected format ITEM_NAME OPERATOR STATE_VALUE, where OPERATOR is one of: {}", + expression, StateCondition.ComparisonType.valuesAndSymbols()); + return; + } + + String itemName = matcher.group(1).trim(); + String operator = matcher.group(2).trim(); + String value = matcher.group(3).trim(); + try { + StateCondition.ComparisonType comparisonType = Objects.requireNonNullElseGet( + StateCondition.ComparisonType.fromSymbol(operator), + () -> StateCondition.ComparisonType.valueOf(operator.toUpperCase(Locale.ROOT))); + parsedConditions.add(new StateCondition(itemName, comparisonType, value, itemRegistry)); + } catch (IllegalArgumentException e) { + logger.warn("Invalid comparison operator: '{}'. Expected one of: {}", operator, + StateCondition.ComparisonType.valuesAndSymbols()); + } + }); + + return parsedConditions; } @Override @@ -128,40 +151,23 @@ 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"); + 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 +179,158 @@ State parseState(@Nullable String stateString) { } class StateCondition { - String itemName; + private String itemName; + private ComparisonType comparisonType; + private String value; + private @Nullable State parsedValue; - ComparisonType comparisonType; - String value; + private ItemRegistry itemRegistry; - boolean quoted = false; - - public StateCondition(String itemName, ComparisonType comparisonType, String value) { + public StateCondition(String itemName, ComparisonType comparisonType, String value, ItemRegistry itemRegistry) { + this.itemRegistry = itemRegistry; 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 + // If unable to parse here, 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; + + // CHANGE // BACK // TO // DEBUG + logger.warn("Evaluating {} with input: {} ({})", this, input, input.getClass().getSimpleName()); + 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)) { + logger.debug("Condition state: '{}' ({}) only supports '==' and '!==' comparisons", state, + state.getClass().getSimpleName()); + return false; + } + lhs = state instanceof Enum ? state : state.toString(); + } + + if (parsedValue == null) { + parsedValue = TypeParser.parseState(item.getAcceptedDataTypes(), value); + if (parsedValue != null) { + // 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 (parsedValue == null) { + // DEBUG LOG HERE + logger.warn("Condition value: '{}' is not compatible with state '{}' ({})", value, state, + state.getClass().getSimpleName()); + return false; + } + } + + rhs = Objects.requireNonNull(parsedValue instanceof StringType ? parsedValue.toString() : parsedValue); + + if (rhs instanceof String && lhs instanceof String) { + // If both are strings, we can do a case-insensitive comparison + lhs = ((String) lhs).toLowerCase(Locale.ROOT); + rhs = ((String) rhs).toLowerCase(Locale.ROOT); + } + + // if (logger.isDebugEnabled()) { + if (itemName.isEmpty()) { + logger.warn("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs, + lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName()); + } else { + logger.warn("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 -> !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: {}: {}", this, e.getMessage()); } + return false; } enum ComparisonType { - EQ, - NEQ + EQ("=="), + NEQ("!="), + GT(">"), + GTE(">="), + LT("<"), + LTE("<="); + + private final String symbol; + + ComparisonType(String symbol) { + this.symbol = symbol; + } + + String getSymbol() { + return symbol; + } + + static @Nullable ComparisonType fromSymbol(String symbol) { + for (ComparisonType type : values()) { + if (type.symbol.equals(symbol)) { + return type; + } + } + return null; + } + + static List valuesAndSymbols() { + return Stream.of(values()).flatMap(entry -> Stream.of(entry.name(), entry.getSymbol())).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..f86fe4a8bd76d 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,36 @@ 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..28fc939925e73 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 ITEM_STATE, e.g. "MyItem EQ OFF". Use quotes around ITEM_STATE to perform string comparison e.g. "'OFF'". ITEM_STATE can be a DecimalType or a QuantityType with a unit.

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..b0d304b25706e 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); @@ -147,6 +178,12 @@ 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()) @@ -203,14 +240,270 @@ 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 testComparisonWithOtherItem() { + NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER); + NumberItem decimalItem = new NumberItem("ItemName"); + StringItem stringItem = new StringItem("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, "!=", "CLOSED", true), // + Arguments.of(contactItem, OpenClosedType.OPEN, "==", "CLOSED", false), // + Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "CLOSED", true), // + Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "OPEN", true), // + Arguments.of(stringItem, s_OPEN, "==", "OPEN", true), // Treat as string when dealing with stringitem + Arguments.of(stringItem, s_OPEN, "==", "'OPEN'", true), // + + // 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 + // For string state, quoted and unquoted values should be treated the same + Arguments.of(stringItem, s_foo, "==", "foo", true), // + Arguments.of(stringItem, s_foo, "==", "'foo'", true), // + + 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", false), // no unit => fail + 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", false), // no unit => fail + 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 testComparisonWithOtherItem(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 testComparisonWithLinkedItem() { + NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER); + NumberItem decimalItem = new NumberItem("ItemName"); + StringItem stringItem = new StringItem("ItemName"); + DimmerItem dimmerItem = new DimmerItem("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"); + + 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), // + + // For string state, quoted and unquoted values should be treated the same + 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'", true), // + Arguments.of(stringItem, s_foo, "!=", "foo", false), // + Arguments.of(stringItem, s_foo, " !=", "foo", false), // + Arguments.of(stringItem, s_foo, "!=", "'foo'", false), // + + 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), // + + 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 testComparisonWithLinkedItem(GenericItem linkedItem, State inputState, String operator, String value, + boolean expected) throws ItemNotFoundException { + + String itemName = linkedItem.getName(); + + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", operator + value))); + when(mockItemRegistry.getItem(itemName)).thenReturn(linkedItem); + when(mockItemChannelLink.getItemName()).thenReturn(itemName); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + profile.onStateUpdateFromHandler(inputState); + verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputState)); } }