[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 <jcode@tanagra.id.au>
This commit is contained in:
jimtng 2024-08-29 00:28:10 +10:00 committed by GitHub
parent c7a2026346
commit e4ce954dc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 807 additions and 153 deletions

View File

@ -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 case: Ignore values from a binding unless some other item(s) have a specific state.
Use cases:
### Configuration
- Ignore values from the binding unless some other item(s) have a specific state.
- Filter out invalid values from the binding.
| 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 Configuration
Possible values for token `OPERATOR` in `conditions`:
| 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 `,`. |
- `EQ` - Equals
- `NEQ` - Not equals
#### State Filter Conditions
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.
### Full Example
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.
```Java
Number:Temperature airconTemperature{
channel="mybinding:mything:mychannel"[profile="basic-profiles:state-filter",conditions="airconPower_item EQ ON",mismatchState="UNDEF"]
Some tips:
- 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.
##### 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 |
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" }
Number:Power PowerUsage {
channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions=">= MinimumPowerLimit", "< MaximumPowerLimit" ]
}
```

View File

@ -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 @@ import org.openhab.transform.basicprofiles.internal.profiles.StateFilterProfile;
@NonNullByDefault
public class StateFilterProfileConfig {
public String conditions = "";
public List<String> conditions = List.of();
public String mismatchState = UnDefType.UNDEF.toString();
public @Nullable String mismatchState;
public String separator = ",";
}

View File

@ -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<ProfileTypeUID> 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,

View File

@ -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.thing.profiles.StateProfile;
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 @@ import org.slf4j.LoggerFactory;
* 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<Class<? extends State>> acceptedDataTypes;
private List<StateCondition> conditions = List.of();
private final ItemRegistry itemRegistry;
private @Nullable State configMismatchState = null;
private final List<StateCondition> conditions;
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<StateCondition> parseConditions(@Nullable String config, String separator) {
if (config == null) {
return List.of();
}
private List<StateCondition> parseConditions(List<String> conditions, String separator) {
List<StateCondition> 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 <EQ|NEQ> 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 class StateFilterProfile implements StateProfile {
@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<Class<? extends State>> acceptedDataTypes) {
// Quoted strings are parsed as StringType
if (stateString == null) {
return null;
@ -173,47 +199,180 @@ public class StateFilterProfile implements StateProfile {
}
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);
}
default:
logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update",
comparisonType);
return false;
/**
* 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<Class<? extends State>> 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());
}
}
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<ComparisonType> fromSymbol(String symbol) {
for (ComparisonType type : values()) {
if (type.symbol.equals(symbol)) {
return Optional.of(type);
}
}
return Optional.empty();
}
static List<String> 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 + "')";
}
}
}

View File

@ -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">
<config-description uri="profile:basic-profiles:state-filter">
<parameter name="conditions" type="text" required="true">
<parameter name="conditions" type="text" required="true" multiple="true">
<label>Conditions</label>
<description>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'".</description>
<description><![CDATA[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.
<br /><br />
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.
<br /><br />
The following operators are supported:
<code>EQ</code> or <code>==</code>,
<code>NE</code>, <code>!=</code>, or <code>&lt;&gt;</code>,
<code>GT</code> or <code>&gt;</code>,
<code>GTE</code> or <code>&gt;=</code>,
<code>LT</code> or <code>&lt;</code>, and
<code>LTE</code> or <code>&lt;=</code>.
]]></description>
</parameter>
<parameter name="mismatchState" type="text">
<label>State for filter rejects</label>
<description>State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType`</description>
<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.</description>
</parameter>
<parameter name="separator" type="text">
<advanced>true</advanced>
<label>Expression Separator</label>
<description>The character/string used to separate multiple conditions in a single line. Defaults to ",".</description>
<default>,</default>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -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. <br /><br /> 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. <br /><br /> The following operators are supported: <code>EQ</code> or <code>==</code>, <code>NE</code>, <code>!=</code>, or <code>&lt;&gt;</code>, <code>GT</code> or <code>&gt;</code>, <code>GTE</code> or <code>&gt;=</code>, <code>LT</code> or <code>&lt;</code>, and <code>LTE</code> or <code>&lt;=</code>.
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 ",".

View File

@ -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<String, Object> 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 class StateFilterProfileTest {
@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 class StateFilterProfileTest {
@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 @@ public class StateFilterProfileTest {
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 class StateFilterProfileTest {
@Test
void testParseStateNonQuotes() {
when(mockContext.getAcceptedDataTypes())
.thenReturn(List.of(UnDefType.class, OnOffType.class, StringType.class));
List<Class<? extends State>> 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<Arguments> 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<Arguments> 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<Arguments> 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));
}
}