diff --git a/bundles/org.openhab.transform.basicprofiles/README.md b/bundles/org.openhab.transform.basicprofiles/README.md index d0acca3b92a..0efbef60229 100644 --- a/bundles/org.openhab.transform.basicprofiles/README.md +++ b/bundles/org.openhab.transform.basicprofiles/README.md @@ -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 diff --git a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java index a099bc0e6b1..30a0ddf8956 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java +++ b/bundles/org.openhab.transform.basicprofiles/src/main/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfile.java @@ -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 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> acceptedDataTypes) { + State parseState(@Nullable String stateString, List> 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 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> 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> 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 windowSize; + + public FunctionType(Function type, Optional 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 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 @Nullable T as(@Nullable Class 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 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 states) { + if (states.isEmpty()) { + logger.debug("Not enough states to calculate median"); + return null; + } + if (newState instanceof QuantityType newStateQuantity) { + Unit unit = newStateQuantity.getUnit(); + List 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 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 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 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 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); } } } diff --git a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java index 9b3d5fe368b..7f4e57686f6 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java +++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java @@ -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 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 testFunctions() { + NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER); + NumberItem decimalItem = new NumberItem("decimalItem"); + List numbers = List.of(1, 2, 3, 4, 5); + List quantities = numbers.stream().map(n -> new QuantityType(n, Units.WATT)).toList(); + List 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 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); + } }