mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 15:11:59 +01:00
[basicprofiles] Add support for functions (DELTA, MEDIAN, AVG, STDDEV, MIN, MAX) in State Filter (#17362)
* [basicprofiles] Add support for functions (DELTA, MEDIAN, AVG, STDDEV, MIN, MAX) in State Filter Support any type of operand on either side of the operator e.g.: `ItemName > 10` and `10 < ItemName` Signed-off-by: Jimmy Tanagra <jcode@tanagra.id.au> Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
parent
091dc80e2a
commit
3321641a2c
@ -198,21 +198,37 @@ Use cases:
|
||||
|
||||
#### State Filter Conditions
|
||||
|
||||
The conditions are defined in the format `[ITEM_NAME] OPERATOR VALUE_OR_ITEM_NAME`, e.g. `MyItem EQ OFF`.
|
||||
The conditions are defined in the format `[LHS_OPERAND] OPERATOR RHS_OPERAND`, 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.
|
||||
|
||||
The `LHS_OPERAND` and the `RHS_OPERAND` can be either one of these:
|
||||
|
||||
- An item name, which will be evaluated to its state.
|
||||
- A type constant, such as `ON`, `OFF`, `UNDEF`, `NULL`, `OPEN`, `CLOSED`, `PLAY`, `PAUSE`, `UP`, `DOWN`, etc.
|
||||
Note that these are unquoted.
|
||||
- A String value, enclosed with single quotes, e.g. `'ON'`.
|
||||
A string value is different to the actual `OnOffType.ON`.
|
||||
To compare against an actual OnOffType, use an unquoted `ON`.
|
||||
- A plain number to represent a `DecimalType`.
|
||||
- A number with a unit to represent a `QuantityType`, for example `1.2 kW`, or `24 °C`.
|
||||
- One of the special functions supported by State Filter:
|
||||
- `$DELTA` to represent the absolute difference between the incoming value and the previously accepted value.
|
||||
- `$AVERAGE`, or `$AVG` to represent the average of the previous unfiltered incoming values.
|
||||
- `$STDDEV` to represent the _population_ standard deviation of the previous unfiltered incoming values.
|
||||
- `$MEDIAN` to represent the median value of the previous unfiltered incoming values.
|
||||
- `$MIN` to represent the minimum value of the previous unfiltered incoming values.
|
||||
- `$MAX` to represent the maximum value of the previous unfiltered incoming values.
|
||||
These are only applicable to numeric states.
|
||||
By default, 5 samples of the previous values are kept.
|
||||
This can be customized by specifying the "window size" or sample count applicable to the function, e.g. `$MEDIAN(10)` will return the median of the last 10 values.
|
||||
All the functions except `$DELTA` support a custom window size.
|
||||
|
||||
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.
|
||||
When `LHS_OPERAND` is omitted, e.g. `> 10, < 100`, the comparisons are applied against the input data from the binding.
|
||||
The `RHS_OPERAND` can be any of the valid values listed above.
|
||||
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.
|
||||
|
||||
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 | |
|
||||
@ -247,6 +263,14 @@ Number:Power PowerUsage {
|
||||
}
|
||||
```
|
||||
|
||||
Filter out incoming data with very small difference from the previous one:
|
||||
|
||||
```java
|
||||
Number:Power PowerUsage {
|
||||
channel="mybinding:mything:mychannel" [ profile="basic-profiles:state-filter", conditions="$DELTA > 10 W" ]
|
||||
}
|
||||
```
|
||||
|
||||
The incoming state can be compared against other items:
|
||||
|
||||
```java
|
||||
|
@ -15,8 +15,12 @@ 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.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
@ -26,11 +30,14 @@ import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import javax.measure.Unit;
|
||||
|
||||
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.DecimalType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.thing.profiles.ProfileCallback;
|
||||
@ -41,6 +48,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.core.util.Statistics;
|
||||
import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileConfig;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@ -51,6 +59,7 @@ import org.slf4j.LoggerFactory;
|
||||
*
|
||||
* @author Arne Seime - Initial contribution
|
||||
* @author Jimmy Tanagra - Expanded the comparison types
|
||||
* @author Jimmy Tanagra - Added support for functions
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class StateFilterProfile implements StateProfile {
|
||||
@ -75,6 +84,12 @@ public class StateFilterProfile implements StateProfile {
|
||||
// - Symbols may be more prevalently used, so check them first
|
||||
"(.*?)(" + OPERATOR_SYMBOL_PATTERN + "|" + OPERATOR_NAME_PATTERN + ")(.*)", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
// Function pattern to match `$NAME` or `$NAME(5)`.
|
||||
// The number represents an optional window size that applies to the function.
|
||||
private final static Pattern FUNCTION_PATTERN = Pattern.compile("\\$(\\w+)(?:\\s*\\(\\s*(\\d+)\\s*\\))?\\s*");
|
||||
|
||||
private final static int DEFAULT_WINDOW_SIZE = 5;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class);
|
||||
|
||||
private final ProfileCallback callback;
|
||||
@ -85,6 +100,14 @@ public class StateFilterProfile implements StateProfile {
|
||||
|
||||
private final @Nullable State configMismatchState;
|
||||
|
||||
private @Nullable Item linkedItem = null;
|
||||
|
||||
private State newState = UnDefType.UNDEF;
|
||||
private State deltaState = UnDefType.UNDEF;
|
||||
private LinkedList<State> previousStates = new LinkedList<>();
|
||||
|
||||
private final int windowSize;
|
||||
|
||||
public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
|
||||
this.callback = callback;
|
||||
this.itemRegistry = itemRegistry;
|
||||
@ -92,14 +115,35 @@ public class StateFilterProfile implements StateProfile {
|
||||
StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
|
||||
if (config != null) {
|
||||
conditions = parseConditions(config.conditions, config.separator);
|
||||
int maxWindowSize = 0;
|
||||
|
||||
if (conditions.isEmpty()) {
|
||||
logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}",
|
||||
callback.getItemChannelLink(), config.conditions);
|
||||
} else {
|
||||
for (StateCondition condition : conditions) {
|
||||
if (condition.lhsState instanceof FunctionType function) {
|
||||
int windowSize = function.getWindowSize();
|
||||
if (windowSize > maxWindowSize) {
|
||||
maxWindowSize = windowSize;
|
||||
}
|
||||
}
|
||||
if (condition.rhsState instanceof FunctionType function) {
|
||||
int windowSize = function.getWindowSize();
|
||||
if (windowSize > maxWindowSize) {
|
||||
maxWindowSize = windowSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
windowSize = maxWindowSize;
|
||||
|
||||
configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
|
||||
} else {
|
||||
conditions = List.of();
|
||||
configMismatchState = null;
|
||||
windowSize = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@ -118,16 +162,17 @@ public class StateFilterProfile implements StateProfile {
|
||||
expression, callback.getItemChannelLink(),
|
||||
StateCondition.ComparisonType.namesAndSymbols());
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
String itemName = matcher.group(1).trim();
|
||||
String lhs = matcher.group(1).trim();
|
||||
String operator = matcher.group(2).trim();
|
||||
String value = matcher.group(3).trim();
|
||||
String rhs = 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));
|
||||
parsedConditions.add(new StateCondition(lhs, comparisonType, rhs));
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("Invalid comparison operator: '{}' in link '{}'. Expected one of: {}", operator,
|
||||
callback.getItemChannelLink(), StateCondition.ComparisonType.namesAndSymbols());
|
||||
@ -159,6 +204,7 @@ public class StateFilterProfile implements StateProfile {
|
||||
|
||||
@Override
|
||||
public void onStateUpdateFromHandler(State state) {
|
||||
newState = state;
|
||||
State resultState = checkCondition(state);
|
||||
if (resultState != null) {
|
||||
logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState);
|
||||
@ -166,6 +212,12 @@ public class StateFilterProfile implements StateProfile {
|
||||
} else {
|
||||
logger.debug("Received state update from handler: {}, not forwarded to item", state);
|
||||
}
|
||||
if (windowSize > 0 && (state instanceof DecimalType || state instanceof QuantityType)) {
|
||||
previousStates.add(state);
|
||||
if (previousStates.size() > windowSize) {
|
||||
previousStates.removeFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@ -177,147 +229,190 @@ public class StateFilterProfile implements StateProfile {
|
||||
return null;
|
||||
}
|
||||
|
||||
String linkedItemName = callback.getItemChannelLink().getItemName();
|
||||
|
||||
if (conditions.stream().allMatch(c -> c.check(linkedItemName, state))) {
|
||||
if (conditions.stream().allMatch(c -> c.check(state))) {
|
||||
return state;
|
||||
} else {
|
||||
return configMismatchState;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable Item getLinkedItem() {
|
||||
if (linkedItem == null) {
|
||||
linkedItem = getItemOrNull(callback.getItemChannelLink().getItemName());
|
||||
}
|
||||
return linkedItem;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
static State parseState(@Nullable String stateString, List<Class<? extends State>> acceptedDataTypes) {
|
||||
State parseState(@Nullable String stateString, List<Class<? extends State>> acceptedDataTypes) {
|
||||
// Quoted strings are parsed as StringType
|
||||
if (stateString == null) {
|
||||
if (stateString == null || stateString.isEmpty()) {
|
||||
return null;
|
||||
} else if (stateString.startsWith("'") && stateString.endsWith("'")) {
|
||||
return new StringType(stateString.substring(1, stateString.length() - 1));
|
||||
} else {
|
||||
return TypeParser.parseState(acceptedDataTypes, stateString);
|
||||
} else if (parseFunction(stateString) instanceof FunctionType function) {
|
||||
return function;
|
||||
} else if (TypeParser.parseState(acceptedDataTypes, stateString) instanceof State state) {
|
||||
return state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
FunctionType parseFunction(String functionDefinition) {
|
||||
if (!functionDefinition.startsWith("$")) {
|
||||
return null;
|
||||
}
|
||||
logger.debug("Parsing function: '{}'", functionDefinition);
|
||||
Matcher matcher = FUNCTION_PATTERN.matcher(functionDefinition);
|
||||
if (!matcher.matches()) {
|
||||
logger.warn("Invalid function definition: '{}'", functionDefinition);
|
||||
return null;
|
||||
}
|
||||
String functionName = matcher.group(1).toUpperCase(Locale.ROOT);
|
||||
try {
|
||||
FunctionType.Function type = FunctionType.Function.valueOf(functionName);
|
||||
|
||||
Optional<Integer> windowSize = Optional.empty();
|
||||
windowSize = Optional.ofNullable(matcher.group(2)).map(Integer::parseInt);
|
||||
return new FunctionType(type, windowSize);
|
||||
} catch (IllegalArgumentException e) {
|
||||
logger.warn("Invalid function name: '{}'. Expected one of: {}", functionName,
|
||||
Stream.of(FunctionType.Function.values()).map(Enum::name).collect(Collectors.joining(", ")));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private @Nullable Item getItemOrNull(String value) {
|
||||
try {
|
||||
return itemRegistry.getItem(value);
|
||||
} catch (ItemNotFoundException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class StateCondition {
|
||||
private String itemName;
|
||||
private ComparisonType comparisonType;
|
||||
private String value;
|
||||
private @Nullable State parsedValue;
|
||||
private String lhsString;
|
||||
private String rhsString;
|
||||
private @Nullable State lhsState;
|
||||
private @Nullable State rhsState;
|
||||
|
||||
public StateCondition(String itemName, ComparisonType comparisonType, String value) {
|
||||
this.itemName = itemName;
|
||||
public StateCondition(String lhs, ComparisonType comparisonType, String rhs) {
|
||||
this.comparisonType = comparisonType;
|
||||
this.value = value;
|
||||
this.lhsString = lhs;
|
||||
this.rhsString = rhs;
|
||||
// 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));
|
||||
this.lhsState = parseState(lhsString, List.of(UnDefType.class));
|
||||
this.rhsState = parseState(rhsString, List.of(UnDefType.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* If the lhs is empty, 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;
|
||||
public boolean check(State input) {
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input,
|
||||
input.getClass().getSimpleName(), callback.getItemChannelLink());
|
||||
}
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input,
|
||||
input.getClass().getSimpleName(), callback.getItemChannelLink());
|
||||
try {
|
||||
// Don't overwrite the object variables. These need to be re-evaluated for each check
|
||||
State lhsState = this.lhsState;
|
||||
State rhsState = this.rhsState;
|
||||
Item lhsItem = null;
|
||||
Item rhsItem = null;
|
||||
|
||||
if (rhsState == null) {
|
||||
rhsItem = getItemOrNull(rhsString);
|
||||
} else if (rhsState instanceof FunctionType) {
|
||||
rhsItem = getLinkedItem();
|
||||
}
|
||||
if (itemName.isEmpty()) {
|
||||
item = itemRegistry.getItem(linkedItemName);
|
||||
state = input;
|
||||
} else {
|
||||
item = itemRegistry.getItem(itemName);
|
||||
state = item.getState();
|
||||
|
||||
if (lhsString.isEmpty()) {
|
||||
lhsItem = getLinkedItem();
|
||||
lhsState = input;
|
||||
} else if (lhsState == null) {
|
||||
lhsItem = getItemOrNull(lhsString);
|
||||
lhsState = itemStateOrParseState(lhsItem, lhsString, rhsItem);
|
||||
|
||||
if (lhsState == null) {
|
||||
logger.debug(
|
||||
"The left hand side of the condition '{}' is not compatible with the right hand side '{}'",
|
||||
lhsString, rhsString);
|
||||
return false;
|
||||
}
|
||||
} else if (lhsState instanceof FunctionType lhsFunction) {
|
||||
lhsItem = getLinkedItem();
|
||||
lhsState = lhsFunction.calculate();
|
||||
if (lhsState == null) {
|
||||
logger.debug("Couldn't calculate the left hand side function '{}'", lhsString);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rhsState == null) {
|
||||
rhsState = itemStateOrParseState(rhsItem, rhsString, lhsItem);
|
||||
}
|
||||
|
||||
// Don't convert QuantityType to other types, so that 1500 != 1500 W
|
||||
if (rhsState != null && !(rhsState instanceof QuantityType)) {
|
||||
// Try to convert it to the same type as the lhs
|
||||
// This allows comparing compatible types, e.g. PercentType vs OnOffType
|
||||
rhsState = rhsState.as(lhsState.getClass());
|
||||
}
|
||||
|
||||
if (rhsState == 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("RHS: '{}' is not compatible with LHS '{}' ({})", rhsString, lhsState,
|
||||
lhsState.getClass().getSimpleName());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
// Java Enums (e.g. OnOffType) are inherently Comparable,
|
||||
// but we don't want to allow comparisons like "ON > OFF"
|
||||
if (lhsState instanceof Comparable && !(lhsState instanceof Enum)) {
|
||||
lhs = lhsState;
|
||||
} 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());
|
||||
logger.debug("LHS: '{}' ({}) only supports '==' and '!==' comparisons", lhsState,
|
||||
lhsState.getClass().getSimpleName());
|
||||
return false;
|
||||
}
|
||||
lhs = state instanceof Enum ? state : state.toString();
|
||||
lhs = lhsState instanceof Enum ? lhsState : lhsState.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());
|
||||
}
|
||||
}
|
||||
|
||||
// From hereon, don't override this.parsedValue,
|
||||
// so it gets checked against Item's state on each call
|
||||
State parsedValue = this.parsedValue;
|
||||
|
||||
// If the values couldn'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);
|
||||
rhs = Objects.requireNonNull(rhsState instanceof StringType ? rhsState.toString() : rhsState);
|
||||
|
||||
if (logger.isDebugEnabled()) {
|
||||
if (itemName.isEmpty()) {
|
||||
if (lhsString.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());
|
||||
logger.debug("Performing a comparison between '{}' state '{}' ({}) and value '{}' ({})",
|
||||
lhsString, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
return switch (comparisonType) {
|
||||
boolean result = switch (comparisonType) {
|
||||
case EQ -> lhs.equals(rhs);
|
||||
case NEQ, NEQ_ALT -> !lhs.equals(rhs);
|
||||
case GT -> ((Comparable) lhs).compareTo(rhs) > 0;
|
||||
@ -325,13 +420,33 @@ public class StateFilterProfile implements StateProfile {
|
||||
case LT -> ((Comparable) lhs).compareTo(rhs) < 0;
|
||||
case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0;
|
||||
};
|
||||
} catch (ItemNotFoundException | IllegalArgumentException | ClassCastException e) {
|
||||
|
||||
if (result) {
|
||||
deltaState = input;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (IllegalArgumentException | ClassCastException e) {
|
||||
logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(),
|
||||
e.getMessage());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private @Nullable State itemStateOrParseState(@Nullable Item item, String value, @Nullable Item oppositeItem) {
|
||||
if (item != null) {
|
||||
return item.getState();
|
||||
}
|
||||
|
||||
if (oppositeItem != null) {
|
||||
List<Class<? extends State>> excludeStringType = oppositeItem.getAcceptedDataTypes().stream()
|
||||
.filter(not(StringType.class::isAssignableFrom)).toList();
|
||||
return TypeParser.parseState(excludeStringType, value);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
enum ComparisonType {
|
||||
EQ("=="),
|
||||
NEQ("!="),
|
||||
@ -367,16 +482,187 @@ public class StateFilterProfile implements StateProfile {
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
Object state = null;
|
||||
return String.format("Condition('%s' %s %s '%s' %s)", lhsString,
|
||||
Objects.requireNonNullElse(lhsState, "").toString(), comparisonType, rhsString,
|
||||
Objects.requireNonNullElse(rhsState, "").toString());
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
state = itemRegistry.getItem(itemName).getState();
|
||||
} catch (ItemNotFoundException ignored) {
|
||||
/**
|
||||
* Represents a function to be applied to the previous states.
|
||||
*/
|
||||
class FunctionType implements State {
|
||||
enum Function {
|
||||
DELTA,
|
||||
AVERAGE,
|
||||
AVG,
|
||||
MEDIAN,
|
||||
STDDEV,
|
||||
MIN,
|
||||
MAX
|
||||
}
|
||||
|
||||
private final Function type;
|
||||
private final Optional<Integer> windowSize;
|
||||
|
||||
public FunctionType(Function type, Optional<Integer> windowSize) {
|
||||
this.type = type;
|
||||
this.windowSize = windowSize;
|
||||
}
|
||||
|
||||
public @Nullable State calculate() {
|
||||
logger.debug("Calculating function: {}", this);
|
||||
int size = previousStates.size();
|
||||
int start = windowSize.map(w -> size - w).orElse(0);
|
||||
List<State> states = start <= 0 ? previousStates : previousStates.subList(start, size);
|
||||
return switch (type) {
|
||||
case DELTA -> calculateDelta();
|
||||
case AVG, AVERAGE -> calculateAverage(states);
|
||||
case MEDIAN -> calculateMedian(states);
|
||||
case STDDEV -> calculateStdDev(states);
|
||||
case MIN -> calculateMin(states);
|
||||
case MAX -> calculateMax(states);
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T extends State> @Nullable T as(@Nullable Class<T> target) {
|
||||
if (target == DecimalType.class || target == QuantityType.class) {
|
||||
return target.cast(calculate());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int getWindowSize() {
|
||||
if (type == Function.DELTA) {
|
||||
// We don't need to keep previous states list to calculate the delta,
|
||||
// the previous state is kept in the deltaState variable
|
||||
return 0;
|
||||
}
|
||||
return windowSize.orElse(DEFAULT_WINDOW_SIZE);
|
||||
}
|
||||
|
||||
public Function getType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String format(String _pattern) {
|
||||
return toFullString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toFullString() {
|
||||
return "$" + type.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return toFullString();
|
||||
}
|
||||
|
||||
private @Nullable State calculateAverage(List<State> states) {
|
||||
if (states.isEmpty()) {
|
||||
logger.debug("Not enough states to calculate sum");
|
||||
return null;
|
||||
}
|
||||
if (newState instanceof QuantityType newStateQuantity) {
|
||||
QuantityType zero = new QuantityType(0, newStateQuantity.getUnit());
|
||||
QuantityType sum = states.stream().map(s -> (QuantityType) s).reduce(zero, QuantityType::add);
|
||||
return sum.divide(BigDecimal.valueOf(states.size()));
|
||||
}
|
||||
BigDecimal sum = states.stream().map(s -> ((DecimalType) s).toBigDecimal()).reduce(BigDecimal.ZERO,
|
||||
BigDecimal::add);
|
||||
return new DecimalType(sum.divide(BigDecimal.valueOf(states.size()), 2, RoundingMode.HALF_EVEN));
|
||||
}
|
||||
|
||||
private @Nullable State calculateMedian(List<State> states) {
|
||||
if (states.isEmpty()) {
|
||||
logger.debug("Not enough states to calculate median");
|
||||
return null;
|
||||
}
|
||||
if (newState instanceof QuantityType newStateQuantity) {
|
||||
Unit<?> unit = newStateQuantity.getUnit();
|
||||
List<BigDecimal> bdStates = states.stream()
|
||||
.map(s -> ((QuantityType) s).toInvertibleUnit(unit).toBigDecimal()).toList();
|
||||
return Optional.ofNullable(Statistics.median(bdStates)).map(median -> new QuantityType(median, unit))
|
||||
.orElse(null);
|
||||
}
|
||||
List<BigDecimal> bdStates = states.stream().map(s -> ((DecimalType) s).toBigDecimal()).toList();
|
||||
return Optional.ofNullable(Statistics.median(bdStates)).map(median -> new DecimalType(median)).orElse(null);
|
||||
}
|
||||
|
||||
private @Nullable State calculateStdDev(List<State> states) {
|
||||
if (states.isEmpty()) {
|
||||
logger.debug("Not enough states to calculate standard deviation");
|
||||
return null;
|
||||
}
|
||||
if (newState instanceof QuantityType newStateQuantity) {
|
||||
QuantityType average = (QuantityType) calculateAverage(states);
|
||||
if (average == null) {
|
||||
return null;
|
||||
}
|
||||
QuantityType zero = new QuantityType(0, newStateQuantity.getUnit());
|
||||
QuantityType variance = states.stream() //
|
||||
.map(s -> {
|
||||
QuantityType delta = ((QuantityType) s).subtract(average);
|
||||
return (QuantityType) delta.multiply(delta.toBigDecimal()); // don't square the unit
|
||||
}) //
|
||||
.reduce(zero, QuantityType::add) // This reduced into a QuantityType
|
||||
.divide(BigDecimal.valueOf(states.size()));
|
||||
return new QuantityType(variance.toBigDecimal().sqrt(MathContext.DECIMAL32), variance.getUnit());
|
||||
}
|
||||
BigDecimal average = Optional.ofNullable((DecimalType) calculateAverage(states))
|
||||
.map(DecimalType::toBigDecimal).orElse(null);
|
||||
if (average == null) {
|
||||
return null;
|
||||
}
|
||||
BigDecimal variance = states.stream().map(s -> {
|
||||
BigDecimal delta = ((DecimalType) s).toBigDecimal().subtract(average);
|
||||
return delta.multiply(delta);
|
||||
}).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(states.size()),
|
||||
MathContext.DECIMAL32);
|
||||
return new DecimalType(variance.sqrt(MathContext.DECIMAL32));
|
||||
}
|
||||
|
||||
private @Nullable State calculateMin(List<State> states) {
|
||||
if (states.isEmpty()) {
|
||||
logger.debug("Not enough states to calculate min");
|
||||
return null;
|
||||
}
|
||||
if (newState instanceof QuantityType newStateQuantity) {
|
||||
return states.stream().map(s -> (QuantityType) s).min(QuantityType::compareTo).orElse(null);
|
||||
}
|
||||
return states.stream().map(s -> ((DecimalType) s).toBigDecimal()).min(BigDecimal::compareTo)
|
||||
.map(DecimalType::new).orElse(null);
|
||||
}
|
||||
|
||||
private @Nullable State calculateMax(List<State> states) {
|
||||
if (states.isEmpty()) {
|
||||
logger.debug("Not enough states to calculate max");
|
||||
return null;
|
||||
}
|
||||
if (newState instanceof QuantityType newStateQuantity) {
|
||||
return states.stream().map(s -> (QuantityType) s).max(QuantityType::compareTo).orElse(null);
|
||||
}
|
||||
return states.stream().map(s -> ((DecimalType) s).toBigDecimal()).max(BigDecimal::compareTo)
|
||||
.map(DecimalType::new).orElse(null);
|
||||
}
|
||||
|
||||
private @Nullable State calculateDelta() {
|
||||
if (deltaState == UnDefType.UNDEF) {
|
||||
logger.debug("No previous data to calculate delta");
|
||||
deltaState = newState;
|
||||
return null;
|
||||
}
|
||||
|
||||
String stateClass = state == null ? "null" : state.getClass().getSimpleName();
|
||||
return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType="
|
||||
+ comparisonType + ", value='" + value + "')";
|
||||
if (newState instanceof QuantityType newStateQuantity) {
|
||||
QuantityType result = newStateQuantity.subtract((QuantityType) deltaState);
|
||||
return result.toBigDecimal().compareTo(BigDecimal.ZERO) < 0 ? result.negate() : result;
|
||||
}
|
||||
BigDecimal result = ((DecimalType) newState).toBigDecimal()
|
||||
.subtract(((DecimalType) deltaState).toBigDecimal());
|
||||
return result.compareTo(BigDecimal.ZERO) < 0 ? new DecimalType(result.negate()) : new DecimalType(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -60,6 +60,7 @@ import org.openhab.core.library.types.PercentType;
|
||||
import org.openhab.core.library.types.QuantityType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.library.unit.SIUnits;
|
||||
import org.openhab.core.library.unit.Units;
|
||||
import org.openhab.core.thing.link.ItemChannelLink;
|
||||
import org.openhab.core.thing.profiles.ProfileCallback;
|
||||
import org.openhab.core.thing.profiles.ProfileContext;
|
||||
@ -265,13 +266,13 @@ public class StateFilterProfileTest {
|
||||
}
|
||||
|
||||
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");
|
||||
NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER);
|
||||
NumberItem decimalItem = new NumberItem("decimalItem");
|
||||
StringItem stringItem = new StringItem("stringItem");
|
||||
SwitchItem switchItem = new SwitchItem("switchItem");
|
||||
DimmerItem dimmerItem = new DimmerItem("dimmerItem");
|
||||
ContactItem contactItem = new ContactItem("contactItem");
|
||||
RollershutterItem rollershutterItem = new RollershutterItem("rollershutterItem");
|
||||
|
||||
QuantityType q_1500W = QuantityType.valueOf("1500 W");
|
||||
DecimalType d_1500 = DecimalType.valueOf("1500");
|
||||
@ -281,176 +282,185 @@ public class StateFilterProfileTest {
|
||||
// StringType s_OPEN = StringType.valueOf("OPEN");
|
||||
|
||||
return Stream.of( //
|
||||
// Test various spacing combinations
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem==OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem ==OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem== OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, " contactItem==OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem==OPEN ", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contact Item==OPEN ", false), //
|
||||
|
||||
// Test swapping lhs and rhs
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "OPEN==contactItem", true), //
|
||||
Arguments.of(decimalItem, d_1500, "10 < decimalItem", true), //
|
||||
Arguments.of(decimalItem, d_1500, "1501 > decimalItem", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "1 > dimmerItem", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "0 < dimmerItem", false), //
|
||||
Arguments.of(powerItem, q_1500W, "2 kW > powerItem", true), //
|
||||
|
||||
// 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), //
|
||||
Arguments.of(stringItem, UnDefType.UNDEF, "stringItem == UNDEF", true), //
|
||||
Arguments.of(dimmerItem, UnDefType.UNDEF, "dimmerItem == UNDEF", true), //
|
||||
Arguments.of(dimmerItem, UnDefType.NULL, "dimmerItem == NULL", true), //
|
||||
Arguments.of(dimmerItem, UnDefType.NULL, "dimmerItem == UNDEF", false), //
|
||||
Arguments.of(decimalItem, UnDefType.NULL, "decimalItem > 10", false), //
|
||||
Arguments.of(decimalItem, UnDefType.NULL, "decimalItem < 10", false), //
|
||||
Arguments.of(decimalItem, UnDefType.NULL, "decimalItem == 10", false), //
|
||||
Arguments.of(decimalItem, UnDefType.NULL, "decimalItem >= 10", false), //
|
||||
Arguments.of(decimalItem, UnDefType.NULL, "decimalItem <= 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), //
|
||||
Arguments.of(stringItem, UnDefType.UNDEF, "stringItem == 'UNDEF'", false), //
|
||||
Arguments.of(stringItem, UnDefType.UNDEF, "stringItem != 'UNDEF'", true), //
|
||||
Arguments.of(stringItem, UnDefType.NULL, "stringItem == 'NULL'", false), //
|
||||
Arguments.of(stringItem, UnDefType.NULL, "stringItem != '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(stringItem, s_NULL, "stringItem == 'NULL'", true), // Comparing String to String
|
||||
Arguments.of(stringItem, s_NULL, "stringItem == NULL", false), // String state != UnDefType
|
||||
Arguments.of(stringItem, s_NULL, "stringItem != NULL", true), //
|
||||
Arguments.of(stringItem, s_UNDEF, "stringItem == 'UNDEF'", true), // Comparing String to String
|
||||
Arguments.of(stringItem, s_UNDEF, "stringItem == UNDEF", false), // String state != UnDefType
|
||||
Arguments.of(stringItem, s_UNDEF, "stringItem != 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), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == UNDEF", false), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != UNDEF", true), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == NULL", false), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != 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), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == OPEN", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != 'OPEN'", true), // String != Enum
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != CLOSED", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == CLOSED", false), //
|
||||
Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem == CLOSED", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem != 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
|
||||
Arguments.of(switchItem, OnOffType.ON, "switchItem == ON", true), //
|
||||
Arguments.of(switchItem, OnOffType.ON, "switchItem != ON", false), //
|
||||
Arguments.of(switchItem, OnOffType.ON, "switchItem != OFF", true), //
|
||||
Arguments.of(switchItem, OnOffType.ON, "switchItem != UNDEF", true), //
|
||||
Arguments.of(switchItem, UnDefType.UNDEF, "switchItem == UNDEF", true), //
|
||||
Arguments.of(switchItem, OnOffType.ON, "switchItem == 'ON'", false), // it's not a string
|
||||
Arguments.of(switchItem, OnOffType.ON, "switchItem != '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), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == 'OPEN'", false), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != 'CLOSED'", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != 'OPEN'", true), //
|
||||
Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == 'CLOSED'", false), //
|
||||
Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem == 'CLOSED'", false), //
|
||||
Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem != '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(stringItem, s_foo, "stringItem == 'foo'", true), //
|
||||
Arguments.of(stringItem, s_foo, "stringItem == foo", false), //
|
||||
Arguments.of(stringItem, s_foo, "stringItem != foo", true), // not quoted -> not a string
|
||||
Arguments.of(stringItem, s_foo, "stringItem <> foo", true), //
|
||||
Arguments.of(stringItem, s_foo, "stringItem != 'foo'", false), //
|
||||
Arguments.of(stringItem, s_foo, "stringItem <> '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), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == 100", true), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem >= 100", true), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem > 50", true), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem >= 50", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem < 50", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem >= 50", false), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem >= 0", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem < 0", false), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem <= 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), //
|
||||
Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem == '100'", false), //
|
||||
Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem != '100'", true), //
|
||||
Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem > '10'", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem == '1500 W'", false), // QuantityType vs String => fail
|
||||
Arguments.of(decimalItem, d_1500, "decimalItem == '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), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem == OFF", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem == ON", false), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem != ON", true), //
|
||||
Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem != OFF", false), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == ON", true), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == OFF", false), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != ON", false), //
|
||||
Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != 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(rollershutterItem, PercentType.HUNDRED, "rollershutterItem == DOWN", true), //
|
||||
Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem == UP", false), //
|
||||
Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem != UP", true), //
|
||||
Arguments.of(rollershutterItem, PercentType.ZERO, "rollershutterItem == UP", true), //
|
||||
Arguments.of(rollershutterItem, PercentType.ZERO, "rollershutterItem != 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(decimalItem, d_1500, "decimalItem eq 1500", true), //
|
||||
Arguments.of(decimalItem, d_1500, "decimalItem == 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, "powerItem eq 1500", false), // no unit => fail
|
||||
Arguments.of(powerItem, q_1500W, "powerItem == 1500", false), // no unit => fail
|
||||
Arguments.of(powerItem, q_1500W, "powerItem eq 1500 cm", false), // wrong unit
|
||||
Arguments.of(powerItem, q_1500W, "powerItem == 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, "powerItem eq 1500 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem eq 1.5 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem eq 2 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem == 1500 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem == 1.5 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem == 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, "powerItem neq 500 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem neq 1500", true), // Not the same type, so not equal
|
||||
Arguments.of(powerItem, q_1500W, "powerItem neq 1500 W", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem neq 1.5 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem != 500 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem != 1500", true), // not the same type
|
||||
Arguments.of(powerItem, q_1500W, "powerItem != 1500 W", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem != 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) //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GT 100 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GT 1 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GT 2 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem > 100 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem > 1 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem > 2 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GTE 1500 W", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GTE 1 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GTE 1.5 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GTE 2 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GTE 2000 mW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem GTE 20", false), // no unit
|
||||
Arguments.of(powerItem, q_1500W, "powerItem >= 1.5 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem >= 2 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem LT 2 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem < 2 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem LTE 2 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem <= 2 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem <= 1 kW", false), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem LTE 1.5 kW", true), //
|
||||
Arguments.of(powerItem, q_1500W, "powerItem <= 1.5 kW", true) //
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
public void testComparingItemWithValue(GenericItem item, State state, String operator, String value,
|
||||
boolean expected) throws ItemNotFoundException {
|
||||
public void testComparingItemWithValue(GenericItem item, State state, String condition, boolean expected)
|
||||
throws ItemNotFoundException {
|
||||
String itemName = item.getName();
|
||||
item.setState(state);
|
||||
|
||||
when(mockContext.getConfiguration())
|
||||
.thenReturn(new Configuration(Map.of("conditions", itemName + operator + value)));
|
||||
when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", condition)));
|
||||
when(mockItemRegistry.getItem(itemName)).thenReturn(item);
|
||||
|
||||
StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
|
||||
@ -643,4 +653,87 @@ public class StateFilterProfileTest {
|
||||
profile.onStateUpdateFromHandler(inputState);
|
||||
verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputState));
|
||||
}
|
||||
|
||||
public static Stream<Arguments> testFunctions() {
|
||||
NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER);
|
||||
NumberItem decimalItem = new NumberItem("decimalItem");
|
||||
List<Number> numbers = List.of(1, 2, 3, 4, 5);
|
||||
List<QuantityType> quantities = numbers.stream().map(n -> new QuantityType(n, Units.WATT)).toList();
|
||||
List<DecimalType> decimals = numbers.stream().map(DecimalType::new).toList();
|
||||
|
||||
return Stream.of( //
|
||||
// test custom window size
|
||||
Arguments.of(decimalItem, "$AVERAGE(3) == 4", decimals, DecimalType.valueOf("5"), true), //
|
||||
Arguments.of(decimalItem, "$AVERAGE(4) == 3.5", decimals, DecimalType.valueOf("5"), true), //
|
||||
|
||||
// default window size is 5
|
||||
Arguments.of(decimalItem, "10 <= $DELTA", decimals, DecimalType.valueOf("10"), false), //
|
||||
Arguments.of(decimalItem, "10 <= $DELTA", decimals, DecimalType.valueOf("11"), true), //
|
||||
Arguments.of(decimalItem, "1 <= $DELTA", decimals, DecimalType.valueOf("5.5"), false), //
|
||||
Arguments.of(decimalItem, "1 <= $DELTA", decimals, DecimalType.valueOf("6"), true), //
|
||||
Arguments.of(decimalItem, "$DELTA >= 1", decimals, DecimalType.valueOf("10"), true), //
|
||||
Arguments.of(decimalItem, "$DELTA >= 1", decimals, DecimalType.valueOf("5.5"), false), //
|
||||
|
||||
Arguments.of(decimalItem, "1 == $MIN", decimals, DecimalType.valueOf("20"), true), //
|
||||
Arguments.of(decimalItem, "0 < $MIN", decimals, DecimalType.valueOf("20"), true), //
|
||||
Arguments.of(decimalItem, "$MIN > 0", decimals, DecimalType.valueOf("20"), true), //
|
||||
|
||||
Arguments.of(decimalItem, "< $MIN", decimals, DecimalType.valueOf("20"), false), //
|
||||
Arguments.of(decimalItem, "< $MIN", decimals, DecimalType.valueOf("-1"), true), //
|
||||
Arguments.of(decimalItem, "> $MIN", decimals, DecimalType.valueOf("-1"), false), //
|
||||
Arguments.of(decimalItem, "> $MIN", decimals, DecimalType.valueOf("2"), true), //
|
||||
|
||||
Arguments.of(decimalItem, "1 == $MAX", decimals, DecimalType.valueOf("20"), false), //
|
||||
Arguments.of(decimalItem, "0 < $MAX", decimals, DecimalType.valueOf("20"), true), //
|
||||
Arguments.of(decimalItem, "6 < $MAX", decimals, DecimalType.valueOf("20"), false), //
|
||||
Arguments.of(decimalItem, "6 > $MAX", decimals, DecimalType.valueOf("1"), true), //
|
||||
|
||||
Arguments.of(decimalItem, "< $MAX", decimals, DecimalType.valueOf("20"), false), //
|
||||
Arguments.of(decimalItem, "< $MAX", decimals, DecimalType.valueOf("-1"), true), //
|
||||
Arguments.of(decimalItem, "> $MAX", decimals, DecimalType.valueOf("-1"), false), //
|
||||
Arguments.of(decimalItem, "> $MAX", decimals, DecimalType.valueOf("20"), true), //
|
||||
|
||||
Arguments.of(decimalItem, "$MEDIAN < 4", decimals, DecimalType.valueOf("2"), true), //
|
||||
Arguments.of(decimalItem, "$MEDIAN(3) < 4", decimals, DecimalType.valueOf("2"), false), //
|
||||
|
||||
Arguments.of(decimalItem, "$STDDEV(3) > 0.8", decimals, DecimalType.valueOf("2"), true), //
|
||||
Arguments.of(decimalItem, "$STDDEV > 1.4", decimals, DecimalType.valueOf("2"), true), //
|
||||
Arguments.of(decimalItem, "$STDDEV < 1.5", decimals, DecimalType.valueOf("2"), true), //
|
||||
// Make sure STDDEV's unit is correct
|
||||
Arguments.of(powerItem, "$STDDEV < 1.5 W", quantities, QuantityType.valueOf("2 W"), true), //
|
||||
Arguments.of(powerItem, "$STDDEV < 1.5 W²", quantities, QuantityType.valueOf("2 W"), false), //
|
||||
|
||||
Arguments.of(decimalItem, "< $AVERAGE", decimals, DecimalType.valueOf("2"), true), //
|
||||
Arguments.of(powerItem, "== $AVERAGE", quantities, QuantityType.valueOf("3 W"), true), //
|
||||
Arguments.of(powerItem, "== $AVERAGE", quantities, DecimalType.valueOf("3"), false), //
|
||||
Arguments.of(powerItem, "> $AVERAGE", quantities, QuantityType.valueOf("2 W"), false), //
|
||||
Arguments.of(powerItem, "> $AVERAGE", quantities, QuantityType.valueOf("4 W"), true), //
|
||||
|
||||
Arguments.of(decimalItem, "2 < $AVERAGE", decimals, DecimalType.valueOf("10"), true), //
|
||||
Arguments.of(decimalItem, "$AVERAGE > 2", decimals, DecimalType.valueOf("10"), true), //
|
||||
Arguments.of(powerItem, "3 W == $AVERAGE", quantities, QuantityType.valueOf("100 W"), true), //
|
||||
Arguments.of(powerItem, "3 == $AVERAGE", quantities, QuantityType.valueOf("100 W"), false) //
|
||||
);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource
|
||||
public void testFunctions(Item item, String condition, List<State> states, State input, boolean expected)
|
||||
throws ItemNotFoundException {
|
||||
when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", condition)));
|
||||
when(mockItemRegistry.getItem(item.getName())).thenReturn(item);
|
||||
when(mockItemChannelLink.getItemName()).thenReturn(item.getName());
|
||||
|
||||
StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
|
||||
|
||||
for (State state : states) {
|
||||
profile.onStateUpdateFromHandler(state);
|
||||
}
|
||||
|
||||
reset(mockCallback);
|
||||
when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink);
|
||||
|
||||
profile.onStateUpdateFromHandler(input);
|
||||
verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(input);
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user