[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>
This commit is contained in:
jimtng 2024-12-04 05:03:15 +10:00 committed by GitHub
parent 156e691d0b
commit 12e7212bd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 644 additions and 241 deletions

View File

@ -198,21 +198,37 @@ Use cases:
#### State Filter Conditions #### 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. 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`. 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`. 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. 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 ##### State Filter Operators
| Name | Symbol | | | 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: The incoming state can be compared against other items:
```java ```java

View File

@ -15,8 +15,12 @@ package org.openhab.transform.basicprofiles.internal.profiles;
import static java.util.function.Predicate.not; import static java.util.function.Predicate.not;
import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID; 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.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@ -26,11 +30,14 @@ import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import java.util.stream.Stream; import java.util.stream.Stream;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item; import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException; import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry; 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.QuantityType;
import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.profiles.ProfileCallback; 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.State;
import org.openhab.core.types.TypeParser; import org.openhab.core.types.TypeParser;
import org.openhab.core.types.UnDefType; import org.openhab.core.types.UnDefType;
import org.openhab.core.util.Statistics;
import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileConfig; import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileConfig;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -51,6 +59,7 @@ import org.slf4j.LoggerFactory;
* *
* @author Arne Seime - Initial contribution * @author Arne Seime - Initial contribution
* @author Jimmy Tanagra - Expanded the comparison types * @author Jimmy Tanagra - Expanded the comparison types
* @author Jimmy Tanagra - Added support for functions
*/ */
@NonNullByDefault @NonNullByDefault
public class StateFilterProfile implements StateProfile { public class StateFilterProfile implements StateProfile {
@ -75,6 +84,12 @@ public class StateFilterProfile implements StateProfile {
// - Symbols may be more prevalently used, so check them first // - Symbols may be more prevalently used, so check them first
"(.*?)(" + OPERATOR_SYMBOL_PATTERN + "|" + OPERATOR_NAME_PATTERN + ")(.*)", Pattern.CASE_INSENSITIVE); "(.*?)(" + 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 Logger logger = LoggerFactory.getLogger(StateFilterProfile.class);
private final ProfileCallback callback; private final ProfileCallback callback;
@ -85,6 +100,14 @@ public class StateFilterProfile implements StateProfile {
private final @Nullable State configMismatchState; 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) { public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
this.callback = callback; this.callback = callback;
this.itemRegistry = itemRegistry; this.itemRegistry = itemRegistry;
@ -92,14 +115,35 @@ public class StateFilterProfile implements StateProfile {
StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class); StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
if (config != null) { if (config != null) {
conditions = parseConditions(config.conditions, config.separator); conditions = parseConditions(config.conditions, config.separator);
int maxWindowSize = 0;
if (conditions.isEmpty()) { if (conditions.isEmpty()) {
logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}", logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}",
callback.getItemChannelLink(), config.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()); configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
} else { } else {
conditions = List.of(); conditions = List.of();
configMismatchState = null; configMismatchState = null;
windowSize = 0;
} }
} }
@ -118,16 +162,17 @@ public class StateFilterProfile implements StateProfile {
expression, callback.getItemChannelLink(), expression, callback.getItemChannelLink(),
StateCondition.ComparisonType.namesAndSymbols()); StateCondition.ComparisonType.namesAndSymbols());
return; return;
} }
String itemName = matcher.group(1).trim(); String lhs = matcher.group(1).trim();
String operator = matcher.group(2).trim(); String operator = matcher.group(2).trim();
String value = matcher.group(3).trim(); String rhs = matcher.group(3).trim();
try { try {
StateCondition.ComparisonType comparisonType = StateCondition.ComparisonType StateCondition.ComparisonType comparisonType = StateCondition.ComparisonType
.fromSymbol(operator).orElseGet( .fromSymbol(operator).orElseGet(
() -> StateCondition.ComparisonType.valueOf(operator.toUpperCase(Locale.ROOT))); () -> StateCondition.ComparisonType.valueOf(operator.toUpperCase(Locale.ROOT)));
parsedConditions.add(new StateCondition(itemName, comparisonType, value)); parsedConditions.add(new StateCondition(lhs, comparisonType, rhs));
} catch (IllegalArgumentException e) { } catch (IllegalArgumentException e) {
logger.warn("Invalid comparison operator: '{}' in link '{}'. Expected one of: {}", operator, logger.warn("Invalid comparison operator: '{}' in link '{}'. Expected one of: {}", operator,
callback.getItemChannelLink(), StateCondition.ComparisonType.namesAndSymbols()); callback.getItemChannelLink(), StateCondition.ComparisonType.namesAndSymbols());
@ -159,6 +204,7 @@ public class StateFilterProfile implements StateProfile {
@Override @Override
public void onStateUpdateFromHandler(State state) { public void onStateUpdateFromHandler(State state) {
newState = state;
State resultState = checkCondition(state); State resultState = checkCondition(state);
if (resultState != null) { if (resultState != null) {
logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState); logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState);
@ -166,6 +212,12 @@ public class StateFilterProfile implements StateProfile {
} else { } else {
logger.debug("Received state update from handler: {}, not forwarded to item", state); 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 @Nullable
@ -177,147 +229,190 @@ public class StateFilterProfile implements StateProfile {
return null; return null;
} }
String linkedItemName = callback.getItemChannelLink().getItemName(); if (conditions.stream().allMatch(c -> c.check(state))) {
if (conditions.stream().allMatch(c -> c.check(linkedItemName, state))) {
return state; return state;
} else { } else {
return configMismatchState; return configMismatchState;
} }
} }
private @Nullable Item getLinkedItem() {
if (linkedItem == null) {
linkedItem = getItemOrNull(callback.getItemChannelLink().getItemName());
}
return linkedItem;
}
@Nullable @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 // Quoted strings are parsed as StringType
if (stateString == null) { if (stateString == null || stateString.isEmpty()) {
return null; return null;
} else if (stateString.startsWith("'") && stateString.endsWith("'")) { } else if (stateString.startsWith("'") && stateString.endsWith("'")) {
return new StringType(stateString.substring(1, stateString.length() - 1)); return new StringType(stateString.substring(1, stateString.length() - 1));
} else { } else if (parseFunction(stateString) instanceof FunctionType function) {
return TypeParser.parseState(acceptedDataTypes, stateString); 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 { class StateCondition {
private String itemName;
private ComparisonType comparisonType; private ComparisonType comparisonType;
private String value; private String lhsString;
private @Nullable State parsedValue; private String rhsString;
private @Nullable State lhsState;
private @Nullable State rhsState;
public StateCondition(String itemName, ComparisonType comparisonType, String value) { public StateCondition(String lhs, ComparisonType comparisonType, String rhs) {
this.itemName = itemName;
this.comparisonType = comparisonType; this.comparisonType = comparisonType;
this.value = value; this.lhsString = lhs;
this.rhsString = rhs;
// Convert quoted strings to StringType, and UnDefTypes to UnDefType // 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 // 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 // Anything else, defer parsing until we're checking the condition
// so we can try based on the item's accepted data types // 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. * Check if the condition is met.
* *
* If the itemName is not empty, the condition is checked against the item's state. * If the lhs is empty, the condition is checked against the input state.
* Otherwise, the condition is checked against the input state.
* *
* @param input the state to check against * @param input the state to check against
* @return true if the condition is met, false otherwise * @return true if the condition is met, false otherwise
*/ */
public boolean check(String linkedItemName, State input) { public boolean check(State input) {
try {
State state;
Item item = null;
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input, logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input,
input.getClass().getSimpleName(), callback.getItemChannelLink()); input.getClass().getSimpleName(), callback.getItemChannelLink());
} }
if (itemName.isEmpty()) {
item = itemRegistry.getItem(linkedItemName); try {
state = input; // 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 (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 { } else {
item = itemRegistry.getItem(itemName); logger.debug("RHS: '{}' is not compatible with LHS '{}' ({})", rhsString, lhsState,
state = item.getState(); lhsState.getClass().getSimpleName());
return false;
}
} }
// Using Object because we could be comparing State or String objects // Using Object because we could be comparing State or String objects
Object lhs; Object lhs;
Object rhs; Object rhs;
// Java Enums (e.g. OnOffType) are Comparable, but we want to treat them as not Comparable // Java Enums (e.g. OnOffType) are inherently Comparable,
if (state instanceof Comparable && !(state instanceof Enum)) { // but we don't want to allow comparisons like "ON > OFF"
lhs = state; if (lhsState instanceof Comparable && !(lhsState instanceof Enum)) {
lhs = lhsState;
} else { } else {
// Only allow EQ and NEQ for non-comparable states // Only allow EQ and NEQ for non-comparable states
if (!(comparisonType == ComparisonType.EQ || comparisonType == ComparisonType.NEQ if (!(comparisonType == ComparisonType.EQ || comparisonType == ComparisonType.NEQ
|| comparisonType == ComparisonType.NEQ_ALT)) { || comparisonType == ComparisonType.NEQ_ALT)) {
logger.debug("Condition state: '{}' ({}) only supports '==' and '!==' comparisons", state, logger.debug("LHS: '{}' ({}) only supports '==' and '!==' comparisons", lhsState,
state.getClass().getSimpleName()); lhsState.getClass().getSimpleName());
return false; return false;
} }
lhs = state instanceof Enum ? state : state.toString(); lhs = lhsState instanceof Enum ? lhsState : lhsState.toString();
} }
if (parsedValue == null) { rhs = Objects.requireNonNull(rhsState instanceof StringType ? rhsState.toString() : rhsState);
// 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);
if (logger.isDebugEnabled()) { if (logger.isDebugEnabled()) {
if (itemName.isEmpty()) { if (lhsString.isEmpty()) {
logger.debug("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs, logger.debug("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs,
lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName()); lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
} else { } else {
logger.debug("Performing a comparison between item '{}' state '{}' ({}) and value '{}' ({})", logger.debug("Performing a comparison between '{}' state '{}' ({}) and value '{}' ({})",
itemName, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName()); lhsString, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
} }
} }
return switch (comparisonType) { boolean result = switch (comparisonType) {
case EQ -> lhs.equals(rhs); case EQ -> lhs.equals(rhs);
case NEQ, NEQ_ALT -> !lhs.equals(rhs); case NEQ, NEQ_ALT -> !lhs.equals(rhs);
case GT -> ((Comparable) lhs).compareTo(rhs) > 0; case GT -> ((Comparable) lhs).compareTo(rhs) > 0;
@ -325,13 +420,33 @@ public class StateFilterProfile implements StateProfile {
case LT -> ((Comparable) lhs).compareTo(rhs) < 0; case LT -> ((Comparable) lhs).compareTo(rhs) < 0;
case LTE -> ((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(), logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(),
e.getMessage()); e.getMessage());
} }
return false; 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 { enum ComparisonType {
EQ("=="), EQ("=="),
NEQ("!="), NEQ("!="),
@ -367,16 +482,187 @@ public class StateFilterProfile implements StateProfile {
@Override @Override
public String toString() { public String toString() {
Object state = null; return String.format("Condition('%s' %s %s '%s' %s)", lhsString,
Objects.requireNonNullElse(lhsState, "").toString(), comparisonType, rhsString,
try { Objects.requireNonNullElse(rhsState, "").toString());
state = itemRegistry.getItem(itemName).getState(); }
} catch (ItemNotFoundException ignored) {
} }
String stateClass = state == null ? "null" : state.getClass().getSimpleName(); /**
return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType=" * Represents a function to be applied to the previous states.
+ comparisonType + ", value='" + value + "')"; */
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;
}
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);
} }
} }
} }

View File

@ -60,6 +60,7 @@ import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType; import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.SIUnits; 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.link.ItemChannelLink;
import org.openhab.core.thing.profiles.ProfileCallback; import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext; import org.openhab.core.thing.profiles.ProfileContext;
@ -265,13 +266,13 @@ public class StateFilterProfileTest {
} }
public static Stream<Arguments> testComparingItemWithValue() { public static Stream<Arguments> testComparingItemWithValue() {
NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER); NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER);
NumberItem decimalItem = new NumberItem("ItemName"); NumberItem decimalItem = new NumberItem("decimalItem");
StringItem stringItem = new StringItem("ItemName"); StringItem stringItem = new StringItem("stringItem");
SwitchItem switchItem = new SwitchItem("ItemName"); SwitchItem switchItem = new SwitchItem("switchItem");
DimmerItem dimmerItem = new DimmerItem("ItemName"); DimmerItem dimmerItem = new DimmerItem("dimmerItem");
ContactItem contactItem = new ContactItem("ItemName"); ContactItem contactItem = new ContactItem("contactItem");
RollershutterItem rollershutterItem = new RollershutterItem("ItemName"); RollershutterItem rollershutterItem = new RollershutterItem("rollershutterItem");
QuantityType q_1500W = QuantityType.valueOf("1500 W"); QuantityType q_1500W = QuantityType.valueOf("1500 W");
DecimalType d_1500 = DecimalType.valueOf("1500"); DecimalType d_1500 = DecimalType.valueOf("1500");
@ -281,176 +282,185 @@ public class StateFilterProfileTest {
// StringType s_OPEN = StringType.valueOf("OPEN"); // StringType s_OPEN = StringType.valueOf("OPEN");
return Stream.of( // 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 // We should be able to check item state is/isn't UNDEF/NULL
// First, when the item state is actually an UnDefType // First, when the item state is actually an UnDefType
// An unquoted value UNDEF/NULL should be treated as an UnDefType // An unquoted value UNDEF/NULL should be treated as an UnDefType
// Only equality comparisons against the matching UnDefType will return true // Only equality comparisons against the matching UnDefType will return true
// Any other comparisons should return false // Any other comparisons should return false
Arguments.of(stringItem, UnDefType.UNDEF, "==", "UNDEF", true), // Arguments.of(stringItem, UnDefType.UNDEF, "stringItem == UNDEF", true), //
Arguments.of(dimmerItem, UnDefType.UNDEF, "==", "UNDEF", true), // Arguments.of(dimmerItem, UnDefType.UNDEF, "dimmerItem == UNDEF", true), //
Arguments.of(dimmerItem, UnDefType.NULL, "==", "NULL", true), // Arguments.of(dimmerItem, UnDefType.NULL, "dimmerItem == NULL", true), //
Arguments.of(dimmerItem, UnDefType.NULL, "==", "UNDEF", false), // Arguments.of(dimmerItem, UnDefType.NULL, "dimmerItem == UNDEF", false), //
Arguments.of(decimalItem, UnDefType.NULL, ">", "10", false), // Arguments.of(decimalItem, UnDefType.NULL, "decimalItem > 10", false), //
Arguments.of(decimalItem, UnDefType.NULL, "<", "10", false), // Arguments.of(decimalItem, UnDefType.NULL, "decimalItem < 10", false), //
Arguments.of(decimalItem, UnDefType.NULL, "==", "10", false), // Arguments.of(decimalItem, UnDefType.NULL, "decimalItem == 10", false), //
Arguments.of(decimalItem, UnDefType.NULL, ">=", "10", false), // Arguments.of(decimalItem, UnDefType.NULL, "decimalItem >= 10", false), //
Arguments.of(decimalItem, UnDefType.NULL, "<=", "10", false), // Arguments.of(decimalItem, UnDefType.NULL, "decimalItem <= 10", false), //
// A quoted value (String) isn't UnDefType // A quoted value (String) isn't UnDefType
Arguments.of(stringItem, UnDefType.UNDEF, "==", "'UNDEF'", false), // Arguments.of(stringItem, UnDefType.UNDEF, "stringItem == 'UNDEF'", false), //
Arguments.of(stringItem, UnDefType.UNDEF, "!=", "'UNDEF'", true), // Arguments.of(stringItem, UnDefType.UNDEF, "stringItem != 'UNDEF'", true), //
Arguments.of(stringItem, UnDefType.NULL, "==", "'NULL'", false), // Arguments.of(stringItem, UnDefType.NULL, "stringItem == 'NULL'", false), //
Arguments.of(stringItem, UnDefType.NULL, "!=", "'NULL'", true), // Arguments.of(stringItem, UnDefType.NULL, "stringItem != 'NULL'", true), //
// When the item state is not an UnDefType // When the item state is not an UnDefType
// UnDefType is special. When unquoted and comparing against a StringItem, // UnDefType is special. When unquoted and comparing against a StringItem,
// don't treat it as a string // don't treat it as a string
Arguments.of(stringItem, s_NULL, "==", "'NULL'", true), // Comparing String to String Arguments.of(stringItem, s_NULL, "stringItem == 'NULL'", true), // Comparing String to String
Arguments.of(stringItem, s_NULL, "==", "NULL", false), // String state != UnDefType Arguments.of(stringItem, s_NULL, "stringItem == NULL", false), // String state != UnDefType
Arguments.of(stringItem, s_NULL, "!=", "NULL", true), // Arguments.of(stringItem, s_NULL, "stringItem != NULL", true), //
Arguments.of(stringItem, s_UNDEF, "==", "'UNDEF'", true), // Comparing String to String Arguments.of(stringItem, s_UNDEF, "stringItem == 'UNDEF'", true), // Comparing String to String
Arguments.of(stringItem, s_UNDEF, "==", "UNDEF", false), // String state != UnDefType Arguments.of(stringItem, s_UNDEF, "stringItem == UNDEF", false), // String state != UnDefType
Arguments.of(stringItem, s_UNDEF, "!=", "UNDEF", true), // Arguments.of(stringItem, s_UNDEF, "stringItem != UNDEF", true), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "UNDEF", false), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == UNDEF", false), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "UNDEF", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != UNDEF", true), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "NULL", false), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == NULL", false), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "NULL", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != NULL", true), //
// Check for OPEN/CLOSED // Check for OPEN/CLOSED
Arguments.of(contactItem, OpenClosedType.OPEN, "==", "OPEN", true), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == OPEN", true), //
Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'OPEN'", true), // String != Enum Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != 'OPEN'", true), // String != Enum
Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "CLOSED", true), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != CLOSED", true), //
Arguments.of(contactItem, OpenClosedType.OPEN, "==", "CLOSED", false), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == CLOSED", false), //
Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "CLOSED", true), // Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem == CLOSED", true), //
Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "OPEN", true), // Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem != OPEN", true), //
// ON/OFF // ON/OFF
Arguments.of(switchItem, OnOffType.ON, "==", "ON", true), // Arguments.of(switchItem, OnOffType.ON, "switchItem == ON", true), //
Arguments.of(switchItem, OnOffType.ON, "!=", "ON", false), // Arguments.of(switchItem, OnOffType.ON, "switchItem != ON", false), //
Arguments.of(switchItem, OnOffType.ON, "!=", "OFF", true), // Arguments.of(switchItem, OnOffType.ON, "switchItem != OFF", true), //
Arguments.of(switchItem, OnOffType.ON, "!=", "UNDEF", true), // Arguments.of(switchItem, OnOffType.ON, "switchItem != UNDEF", true), //
Arguments.of(switchItem, UnDefType.UNDEF, "==", "UNDEF", true), // Arguments.of(switchItem, UnDefType.UNDEF, "switchItem == UNDEF", true), //
Arguments.of(switchItem, OnOffType.ON, "==", "'ON'", false), // it's not a string Arguments.of(switchItem, OnOffType.ON, "switchItem == 'ON'", false), // it's not a string
Arguments.of(switchItem, OnOffType.ON, "!=", "'ON'", true), // incompatible types Arguments.of(switchItem, OnOffType.ON, "switchItem != 'ON'", true), // incompatible types
// Enum types != String // Enum types != String
Arguments.of(contactItem, OpenClosedType.OPEN, "==", "'OPEN'", false), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == 'OPEN'", false), //
Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'CLOSED'", true), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != 'CLOSED'", true), //
Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'OPEN'", true), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem != 'OPEN'", true), //
Arguments.of(contactItem, OpenClosedType.OPEN, "==", "'CLOSED'", false), // Arguments.of(contactItem, OpenClosedType.OPEN, "contactItem == 'CLOSED'", false), //
Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "'CLOSED'", false), // Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem == 'CLOSED'", false), //
Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "'CLOSED'", true), // Arguments.of(contactItem, OpenClosedType.CLOSED, "contactItem != 'CLOSED'", true), //
// non UnDefType checks // non UnDefType checks
// String constants must be quoted // String constants must be quoted
Arguments.of(stringItem, s_foo, "==", "'foo'", true), // Arguments.of(stringItem, s_foo, "stringItem == 'foo'", true), //
Arguments.of(stringItem, s_foo, "==", "foo", false), // Arguments.of(stringItem, s_foo, "stringItem == foo", false), //
Arguments.of(stringItem, s_foo, "!=", "foo", true), // not quoted -> not a string Arguments.of(stringItem, s_foo, "stringItem != foo", true), // not quoted -> not a string
Arguments.of(stringItem, s_foo, "<>", "foo", true), // Arguments.of(stringItem, s_foo, "stringItem <> foo", true), //
Arguments.of(stringItem, s_foo, " <>", "foo", true), // Arguments.of(stringItem, s_foo, "stringItem != 'foo'", false), //
Arguments.of(stringItem, s_foo, "<> ", "foo", true), // Arguments.of(stringItem, s_foo, "stringItem <> 'foo'", false), //
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, "dimmerItem == 100", true), //
Arguments.of(dimmerItem, PercentType.HUNDRED, ">=", "100", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem >= 100", true), //
Arguments.of(dimmerItem, PercentType.HUNDRED, ">", "50", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem > 50", true), //
Arguments.of(dimmerItem, PercentType.HUNDRED, ">=", "50", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem >= 50", true), //
Arguments.of(dimmerItem, PercentType.ZERO, "<", "50", true), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem < 50", true), //
Arguments.of(dimmerItem, PercentType.ZERO, ">=", "50", false), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem >= 50", false), //
Arguments.of(dimmerItem, PercentType.ZERO, ">=", "0", true), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem >= 0", true), //
Arguments.of(dimmerItem, PercentType.ZERO, "<", "0", false), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem < 0", false), //
Arguments.of(dimmerItem, PercentType.ZERO, "<=", "0", true), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem <= 0", true), //
// Numeric vs Strings aren't comparable // Numeric vs Strings aren't comparable
Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "'100'", false), // Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem == '100'", false), //
Arguments.of(rollershutterItem, PercentType.HUNDRED, "!=", "'100'", true), // Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem != '100'", true), //
Arguments.of(rollershutterItem, PercentType.HUNDRED, ">", "'10'", false), // Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem > '10'", false), //
Arguments.of(powerItem, q_1500W, "==", "'1500 W'", false), // QuantityType vs String => fail Arguments.of(powerItem, q_1500W, "powerItem == '1500 W'", false), // QuantityType vs String => fail
Arguments.of(decimalItem, d_1500, "==", "'1500'", false), // Arguments.of(decimalItem, d_1500, "decimalItem == '1500'", false), //
// Compatible type castings are supported // Compatible type castings are supported
Arguments.of(dimmerItem, PercentType.ZERO, "==", "OFF", true), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem == OFF", true), //
Arguments.of(dimmerItem, PercentType.ZERO, "==", "ON", false), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem == ON", false), //
Arguments.of(dimmerItem, PercentType.ZERO, "!=", "ON", true), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem != ON", true), //
Arguments.of(dimmerItem, PercentType.ZERO, "!=", "OFF", false), // Arguments.of(dimmerItem, PercentType.ZERO, "dimmerItem != OFF", false), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "ON", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == ON", true), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "OFF", false), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem == OFF", false), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "ON", false), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != ON", false), //
Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "OFF", true), // Arguments.of(dimmerItem, PercentType.HUNDRED, "dimmerItem != OFF", true), //
// UpDownType gets converted to PercentType for comparison // UpDownType gets converted to PercentType for comparison
Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "DOWN", true), // Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem == DOWN", true), //
Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "UP", false), // Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem == UP", false), //
Arguments.of(rollershutterItem, PercentType.HUNDRED, "!=", "UP", true), // Arguments.of(rollershutterItem, PercentType.HUNDRED, "rollershutterItem != UP", true), //
Arguments.of(rollershutterItem, PercentType.ZERO, "==", "UP", true), // Arguments.of(rollershutterItem, PercentType.ZERO, "rollershutterItem == UP", true), //
Arguments.of(rollershutterItem, PercentType.ZERO, "!=", "DOWN", true), // Arguments.of(rollershutterItem, PercentType.ZERO, "rollershutterItem != DOWN", true), //
Arguments.of(decimalItem, d_1500, " eq ", "1500", true), // Arguments.of(decimalItem, d_1500, "decimalItem eq 1500", true), //
Arguments.of(decimalItem, d_1500, " eq ", "1500", true), // Arguments.of(decimalItem, d_1500, "decimalItem == 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, "powerItem eq 1500", false), // no unit => fail
Arguments.of(powerItem, q_1500W, "==", "1500", false), // no unit => fail Arguments.of(powerItem, q_1500W, "powerItem == 1500", false), // no unit => fail
Arguments.of(powerItem, q_1500W, " eq ", "1500 cm", false), // wrong unit Arguments.of(powerItem, q_1500W, "powerItem eq 1500 cm", false), // wrong unit
Arguments.of(powerItem, q_1500W, "==", "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, "powerItem eq 1500 W", true), //
Arguments.of(powerItem, q_1500W, " eq ", "1.5 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem eq 1.5 kW", true), //
Arguments.of(powerItem, q_1500W, " eq ", "2 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem eq 2 kW", false), //
Arguments.of(powerItem, q_1500W, "==", "1500 W", true), // Arguments.of(powerItem, q_1500W, "powerItem == 1500 W", true), //
Arguments.of(powerItem, q_1500W, "==", "1.5 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem == 1.5 kW", true), //
Arguments.of(powerItem, q_1500W, "==", "2 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem == 2 kW", false), //
Arguments.of(powerItem, q_1500W, " neq ", "500 W", true), // Arguments.of(powerItem, q_1500W, "powerItem neq 500 W", true), //
Arguments.of(powerItem, q_1500W, " neq ", "1500", true), // Not the same type, so not equal Arguments.of(powerItem, q_1500W, "powerItem neq 1500", true), // Not the same type, so not equal
Arguments.of(powerItem, q_1500W, " neq ", "1500 W", false), // Arguments.of(powerItem, q_1500W, "powerItem neq 1500 W", false), //
Arguments.of(powerItem, q_1500W, " neq ", "1.5 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem neq 1.5 kW", false), //
Arguments.of(powerItem, q_1500W, "!=", "500 W", true), // Arguments.of(powerItem, q_1500W, "powerItem != 500 W", true), //
Arguments.of(powerItem, q_1500W, "!=", "1500", true), // not the same type Arguments.of(powerItem, q_1500W, "powerItem != 1500", true), // not the same type
Arguments.of(powerItem, q_1500W, "!=", "1500 W", false), // Arguments.of(powerItem, q_1500W, "powerItem != 1500 W", false), //
Arguments.of(powerItem, q_1500W, "!=", "1.5 kW", 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, "powerItem GT 100 W", true), //
Arguments.of(powerItem, q_1500W, " GT ", "1 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem GT 1 kW", true), //
Arguments.of(powerItem, q_1500W, " GT ", "2 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem GT 2 kW", false), //
Arguments.of(powerItem, q_1500W, ">", "100 W", true), // Arguments.of(powerItem, q_1500W, "powerItem > 100 W", true), //
Arguments.of(powerItem, q_1500W, ">", "1 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem > 1 kW", true), //
Arguments.of(powerItem, q_1500W, ">", "2 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem > 2 kW", false), //
Arguments.of(powerItem, q_1500W, " GTE ", "1500 W", true), // Arguments.of(powerItem, q_1500W, "powerItem GTE 1500 W", true), //
Arguments.of(powerItem, q_1500W, " GTE ", "1 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem GTE 1 kW", true), //
Arguments.of(powerItem, q_1500W, " GTE ", "1.5 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem GTE 1.5 kW", true), //
Arguments.of(powerItem, q_1500W, " GTE ", "2 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem GTE 2 kW", false), //
Arguments.of(powerItem, q_1500W, " GTE ", "2000 mW", true), // Arguments.of(powerItem, q_1500W, "powerItem GTE 2000 mW", true), //
Arguments.of(powerItem, q_1500W, " GTE ", "20", false), // no unit Arguments.of(powerItem, q_1500W, "powerItem GTE 20", false), // no unit
Arguments.of(powerItem, q_1500W, ">=", "1.5 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem >= 1.5 kW", true), //
Arguments.of(powerItem, q_1500W, ">=", "2 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem >= 2 kW", false), //
Arguments.of(powerItem, q_1500W, " LT ", "2 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem LT 2 kW", true), //
Arguments.of(powerItem, q_1500W, "<", "2 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem < 2 kW", true), //
Arguments.of(powerItem, q_1500W, " LTE ", "2 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem LTE 2 kW", true), //
Arguments.of(powerItem, q_1500W, "<=", "2 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem <= 2 kW", true), //
Arguments.of(powerItem, q_1500W, "<=", "1 kW", false), // Arguments.of(powerItem, q_1500W, "powerItem <= 1 kW", false), //
Arguments.of(powerItem, q_1500W, " LTE ", "1.5 kW", true), // Arguments.of(powerItem, q_1500W, "powerItem LTE 1.5 kW", true), //
Arguments.of(powerItem, q_1500W, "<=", "1.5 kW", true) // Arguments.of(powerItem, q_1500W, "powerItem <= 1.5 kW", true) //
); );
} }
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
public void testComparingItemWithValue(GenericItem item, State state, String operator, String value, public void testComparingItemWithValue(GenericItem item, State state, String condition, boolean expected)
boolean expected) throws ItemNotFoundException { throws ItemNotFoundException {
String itemName = item.getName(); String itemName = item.getName();
item.setState(state); item.setState(state);
when(mockContext.getConfiguration()) when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", condition)));
.thenReturn(new Configuration(Map.of("conditions", itemName + operator + value)));
when(mockItemRegistry.getItem(itemName)).thenReturn(item); when(mockItemRegistry.getItem(itemName)).thenReturn(item);
StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
@ -643,4 +653,87 @@ public class StateFilterProfileTest {
profile.onStateUpdateFromHandler(inputState); profile.onStateUpdateFromHandler(inputState);
verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(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);
}
} }