diff --git a/bundles/org.openhab.transform.basicprofiles/README.md b/bundles/org.openhab.transform.basicprofiles/README.md index 433c193072c61..ca201ef0af95b 100644 --- a/bundles/org.openhab.transform.basicprofiles/README.md +++ b/bundles/org.openhab.transform.basicprofiles/README.md @@ -242,6 +242,10 @@ The `LHS_OPERAND` and the `RHS_OPERAND` can be either one of these: 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. +In the case of comparisons and calculations involving `QuantityType` values, all the values are converted to the Unit of the linked Item before the calculation and/or comparison is done. +Note: if the binding sends a value that cannot be converted to the Unit of the linked Item, then that value is excluded. +e.g. if the linked item has a Unit of `Units.METRE` and the binding sends a value of `Units.CELSIUS` then the value is ignored. + 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 `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. 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 d717b25271064..3cc0068eaf23f 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 @@ -37,6 +37,7 @@ import org.openhab.core.items.Item; import org.openhab.core.items.ItemNotFoundException; import org.openhab.core.items.ItemRegistry; +import org.openhab.core.library.items.NumberItem; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.QuantityType; import org.openhab.core.library.types.StringType; @@ -53,6 +54,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import tech.units.indriya.AbstractUnit; + /** * Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not* * met. @@ -60,6 +63,7 @@ * @author Arne Seime - Initial contribution * @author Jimmy Tanagra - Expanded the comparison types * @author Jimmy Tanagra - Added support for functions + * @author Andrew Fiddian-Green - Normalise calculations based on the Unit of the linked Item */ @NonNullByDefault public class StateFilterProfile implements StateProfile { @@ -103,48 +107,50 @@ public class StateFilterProfile implements StateProfile { private @Nullable Item linkedItem = null; private State newState = UnDefType.UNDEF; + + // single cached numeric state for use in conjunction with DELTA and DELTA_PERCENT functions private Optional acceptedState = Optional.empty(); - private LinkedList previousStates = new LinkedList<>(); + + // cached list of prior numeric states for use in conjunction with AVG, MEDIAN, STDDEV, MIN, MAX functions + private final List previousStates = new LinkedList<>(); private final int windowSize; + // reference (zero based) system unit for conversions + private @Nullable Unit systemUnit = null; + private boolean systemUnitInitialized = false; + public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) { this.callback = callback; this.itemRegistry = itemRegistry; 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; - } + + 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; - } + } + 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; } + + windowSize = maxWindowSize; + configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes()); } private List parseConditions(List conditions, String separator) { @@ -204,6 +210,10 @@ public void onCommandFromHandler(Command command) { @Override public void onStateUpdateFromHandler(State state) { + if (!isAllowed(state)) { + logger.debug("Received non allowed state update from handler: {}, ignored", state); + return; + } newState = state; State resultState = checkCondition(state); if (resultState != null) { @@ -212,7 +222,7 @@ public void onStateUpdateFromHandler(State state) { } else { logger.debug("Received state update from handler: {}, not forwarded to item", state); } - if (windowSize > 0 && (state instanceof DecimalType || state instanceof QuantityType)) { + if (windowSize > 0 && isCacheable(state)) { previousStates.add(state); if (previousStates.size() > windowSize) { previousStates.removeFirst(); @@ -230,7 +240,9 @@ private State checkCondition(State state) { } if (conditions.stream().allMatch(c -> c.check(state))) { - acceptedState = Optional.of(state); + if (isCacheable(state)) { + acceptedState = Optional.of(state); + } return state; } else { return configMismatchState; @@ -316,7 +328,7 @@ public StateCondition(String lhs, ComparisonType comparisonType, String rhs) { /** * Check if the condition is met. - * + * * If the lhs is empty, the condition is checked against the input state. * * @param input the state to check against @@ -342,6 +354,9 @@ public boolean check(State input) { if (rhsFunction.alwaysAccept()) { return true; } + if (rhsFunction.getType() == FunctionType.Function.DELTA) { + isDeltaCheck = true; + } rhsItem = getLinkedItem(); } @@ -397,10 +412,6 @@ public boolean check(State input) { // Don't convert QuantityType to other types, so that 1500 != 1500 W if (rhsState != null && !(rhsState instanceof QuantityType)) { - if (rhsState instanceof FunctionType rhsFunction - && rhsFunction.getType() == FunctionType.Function.DELTA) { - isDeltaCheck = true; - } // 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()); @@ -438,9 +449,14 @@ public boolean check(State input) { rhs = Objects.requireNonNull(rhsState instanceof StringType ? rhsState.toString() : rhsState); - if (isDeltaCheck && rhs instanceof QuantityType rhsQty && lhs instanceof QuantityType lhsQty) { - if (rhsQty.toUnitRelative(lhsQty.getUnit()) instanceof QuantityType relativeRhs) { - rhs = relativeRhs; + if ((rhs instanceof QuantityType rhsQty) && (lhs instanceof QuantityType lhsQty)) { + if (isDeltaCheck) { + if (rhsQty.toUnitRelative(lhsQty.getUnit()) instanceof QuantityType relativeRhs) { + rhs = relativeRhs; + } + } else if (hasSystemUnit()) { + lhs = toSystemUnitQuantityType(lhsQty) instanceof QuantityType lhsSU ? lhsSU : lhs; + rhs = toSystemUnitQuantityType(rhsQty) instanceof QuantityType rhsSU ? rhsSU : rhs; } } @@ -454,13 +470,14 @@ public boolean check(State input) { } } + @SuppressWarnings({ "rawtypes", "unchecked" }) boolean result = switch (comparisonType) { case EQ -> lhs.equals(rhs); case NEQ, NEQ_ALT -> !lhs.equals(rhs); - case GT -> ((Comparable) lhs).compareTo(rhs) > 0; - case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0; - case LT -> ((Comparable) lhs).compareTo(rhs) < 0; - case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0; + case GT -> ((Comparable) lhs).compareTo(rhs) > 0; + case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0; + case LT -> ((Comparable) lhs).compareTo(rhs) < 0; + case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0; }; return result; @@ -551,18 +568,31 @@ public FunctionType(Function type, Optional 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 DELTA_PERCENT -> calculateDeltaPercent(); - case AVG, AVERAGE -> calculateAverage(states); - case MEDIAN -> calculateMedian(states); - case STDDEV -> calculateStdDev(states); - case MIN -> calculateMin(states); - case MAX -> calculateMax(states); - }; + State result; + switch (type) { + case DELTA -> result = calculateDelta(); + case DELTA_PERCENT -> result = calculateDeltaPercent(); + default -> { + int size = previousStates.size(); + Integer start = windowSize.map(w -> size - w).orElse(0); + List values = toBigDecimals( + start == null || start <= 0 ? previousStates : previousStates.subList(start, size)); + if (values.isEmpty()) { + logger.debug("Not enough states to calculate {}", type); + result = null; + } else { + switch (type) { + case AVG, AVERAGE -> result = calculateAverage(values); + case MEDIAN -> result = calculateMedian(values); + case STDDEV -> result = calculateStdDev(values); + case MIN -> result = calculateMin(values); + case MAX -> result = calculateMax(values); + default -> result = null; + } + } + } + } + return result; } /** @@ -579,11 +609,8 @@ public boolean alwaysAccept() { } if (type == Function.DELTA_PERCENT) { // avoid division by zero - if (acceptedState.get() instanceof QuantityType base) { - return base.toBigDecimal().compareTo(BigDecimal.ZERO) == 0; - } - if (acceptedState.get() instanceof DecimalType base) { - return base.toBigDecimal().compareTo(BigDecimal.ZERO) == 0; + if (toBigDecimal(acceptedState.get()) instanceof BigDecimal base) { + return base.compareTo(BigDecimal.ZERO) == 0; } } return false; @@ -591,6 +618,7 @@ public boolean alwaysAccept() { @Override public @Nullable T as(@Nullable Class target) { + // TODO @andrewfg: do we need to change this ?? if (target == DecimalType.class || target == QuantityType.class) { return target.cast(calculate()); } @@ -603,7 +631,7 @@ public int getWindowSize() { // the previous state is kept in the acceptedState variable return 0; } - return windowSize.orElse(DEFAULT_WINDOW_SIZE); + return windowSize.isPresent() ? windowSize.get() : DEFAULT_WINDOW_SIZE; } public Function getType() { @@ -625,126 +653,179 @@ 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 calculateAverage(List values) { + return Optional + .ofNullable(values.stream().reduce(BigDecimal.ZERO, BigDecimal::add) + .divide(BigDecimal.valueOf(values.size()), MathContext.DECIMAL32)) + .map(o -> toState(o)).orElse(null); } - 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 calculateMedian(List values) { + return Optional.ofNullable(Statistics.median(values)).map(o -> toState(o)).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); + private @Nullable State calculateStdDev(List values) { + BigDecimal average = values.stream().reduce(BigDecimal.ZERO, BigDecimal::add) + .divide(BigDecimal.valueOf(values.size()), 2, RoundingMode.HALF_EVEN); + + BigDecimal variance = values.stream().map(value -> { + BigDecimal delta = value.subtract(average); return delta.multiply(delta); - }).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(states.size()), + }).reduce(BigDecimal.ZERO, BigDecimal::add).divide(BigDecimal.valueOf(values.size()), MathContext.DECIMAL32); - return new DecimalType(variance.sqrt(MathContext.DECIMAL32)); + + return toState(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 calculateMin(List values) { + return Optional.ofNullable(values.stream().min(BigDecimal::compareTo).orElse(null)).map(o -> toState(o)) + .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 calculateMax(List values) { + return Optional.ofNullable(values.stream().max(BigDecimal::compareTo).orElse(null)).map(o -> toState(o)) + .orElse(null); } private @Nullable State calculateDelta() { - if (acceptedState.isEmpty()) { - return null; - } - if (newState instanceof QuantityType newStateQuantity) { - QuantityType result = newStateQuantity.subtract((QuantityType) acceptedState.get()); - return result.toBigDecimal().compareTo(BigDecimal.ZERO) < 0 ? result.negate() : result; - } - BigDecimal result = ((DecimalType) newState).toBigDecimal() - .subtract(((DecimalType) acceptedState.get()).toBigDecimal()) // - .abs(); - return new DecimalType(result); + return acceptedState.isPresent() // + && toBigDecimal(acceptedState.get()) instanceof BigDecimal acceptedValue + && toBigDecimal(newState) instanceof BigDecimal newValue // + ? toState(newValue.subtract(acceptedValue).abs()) + : null; } private @Nullable State calculateDeltaPercent() { - if (acceptedState.isEmpty()) { - return null; - } - State calculatedDelta = calculateDelta(); - BigDecimal bdDelta; - BigDecimal bdBase; - if (acceptedState.get() instanceof QuantityType acceptedStateQuantity) { - // Assume that delta and base are in the same unit - bdDelta = ((QuantityType) calculatedDelta).toBigDecimal(); - bdBase = acceptedStateQuantity.toBigDecimal(); - } else { - bdDelta = ((DecimalType) calculatedDelta).toBigDecimal(); - bdBase = ((DecimalType) acceptedState.get()).toBigDecimal(); - } - bdBase = bdBase.abs(); - BigDecimal percent = bdDelta.multiply(BigDecimal.valueOf(100)).divide(bdBase, 2, RoundingMode.HALF_EVEN); - return new DecimalType(percent); + return acceptedState.isPresent() // + && toBigDecimal(acceptedState.get()) instanceof BigDecimal acceptedValue + && toBigDecimal(newState) instanceof BigDecimal newValue + // percent is dimension-less; we must return DecimalType + ? new DecimalType(newValue.subtract(acceptedValue).multiply(BigDecimal.valueOf(100)) + .divide(acceptedValue, MathContext.DECIMAL32).abs()) + : null; } } + + /** + * Return true if 'systemUnit' is defined. The first call to this method initialises 'systemUnit' to its + * (effectively) final value, so if this method returns 'true' we can safely use 'Objects.requireNonNull()' + * thereafter to assert that 'systemUnit' is indeed non- null. The {@link Unit} is initialized based on the + * system unit of the linked {@link Item}. If there is no linked Item, or it is not a {@link NumberItem} or + * if the Item does not have a {@link Unit}, then 'systemUnit' is null and this method returns false. + */ + protected synchronized boolean hasSystemUnit() { + if (!systemUnitInitialized) { + systemUnitInitialized = true; + systemUnit = getLinkedItem() instanceof NumberItem item && item.getUnit() instanceof Unit unit + ? unit.getSystemUnit() + : null; + } + return systemUnit != null; + } + + /** + * Convert a {@link State} to a {@link BigDecimal}. If it is a {@link QuantityType} and there is a 'systemUnit' its + * value is converted (if possible) to the 'systemUnit' before converting it to a {@link BigDecimal}. Returns null + * if the {@link State} does not have a numeric value, or if the conversion to 'systemUnit' fails. + * + * @return a {@link BigDecimal} or null. + */ + protected @Nullable BigDecimal toBigDecimal(State state) { + if (state instanceof DecimalType decimalType) { + return decimalType.toBigDecimal(); + } + if (state instanceof QuantityType quantityType) { + return hasSystemUnit() // + ? toSystemUnitQuantityType(state) instanceof QuantityType suQuantityType + ? suQuantityType.toBigDecimal() + : null + : quantityType.toBigDecimal(); + } + return state.as(DecimalType.class) instanceof DecimalType decimalType // + ? decimalType.toBigDecimal() + : null; + } + + /** + * Convert a list of {@link State} to a list of {@link BigDecimal} values. + * + * @param states list of {@link State} values. + * @return list of {@link BigDecimal} values. + */ + protected List toBigDecimals(List states) { + return states.stream().map(s -> toBigDecimal(s)).filter(Objects::nonNull).toList(); + } + + /** + * Create a new {@link State} from the given {@link BigDecimal} value. If there is a 'systemUnit' it creates a + * {@link QuantityType} based on that unit. Otherwise it creates a {@link DecimalType}. + * + * @return a {@link QuantityType} or a {@link DecimalType} + */ + protected State toState(BigDecimal value) { + return hasSystemUnit() // + ? new QuantityType<>(value, Objects.requireNonNull(systemUnit)) + : new DecimalType(value); + } + + /** + * Convert a {@link State} to a {@link QuantityType} with its value converted to the 'systemUnit'. + * Returns null if the state is not a {@link QuantityType} or it does not convert to 'systemUnit'. + * + * @return a {@link QuantityType} based on 'systemUnit'. + */ + protected @Nullable QuantityType toSystemUnitQuantityType(State state) { + return state instanceof QuantityType quantityType && hasSystemUnit() // + ? toInvertibleUnit(quantityType, Objects.requireNonNull(systemUnit)) + : null; + } + + /** + * Convert the given {@link QuantityType} to an equivalent based on the target {@link Unit}. The conversion can be + * made to both inverted and non-inverted units, so invertible type conversions (e.g. Mirek <=> Kelvin) are + * supported. + *

+ * Note: we can use {@link QuantityType.toInvertibleUnit()} if OH Core PR #4561 is merged. + * + * @param source the {@link QuantityType} to be converted. + * @param targetUnit the {@link Unit} to convert to. + * + * @return a new {@link QuantityType} based on 'systemUnit' or null. + */ + protected @Nullable QuantityType toInvertibleUnit(QuantityType source, Unit targetUnit) { + Unit sourceSystemUnit = source.getUnit().getSystemUnit(); + if (!targetUnit.equals(sourceSystemUnit) && !targetUnit.isCompatible(AbstractUnit.ONE) + && sourceSystemUnit.inverse().isCompatible(targetUnit)) { + QuantityType sourceInItsSystemUnit = source.toUnit(sourceSystemUnit); + return sourceInItsSystemUnit != null ? sourceInItsSystemUnit.inverse().toUnit(targetUnit) : null; + } + return source.toUnit(targetUnit); + } + + /** + * Check if the given {@link State} is allowed. Non -allowed states are those which are a {@link QuantityType} + * and if there is a 'systemUnit' not compatible with that. + * + * @param state the incoming state. + * @return true if allowed. + */ + protected boolean isAllowed(State state) { + return hasSystemUnit() // + ? toSystemUnitQuantityType(state) != null + : true; + } + + /** + * Check if the given {@link State} is suitable to be cached. This means it is suitable to add to the + * 'previousStates' list and/or to set to the 'acceptedState' field. This means that either there is a + * 'systemUnit' with which 'state' is compatible, or it can provide a {@link DecimalType} value. + * + * @param state the {@link State} to be tested. + * @return true if the 'state' is suitable to be cached. + */ + protected boolean isCacheable(State state) { + return hasSystemUnit() // + ? toSystemUnitQuantityType(state) != null + : state.as(DecimalType.class) != null; + } } 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 3fc2468217406..f62a97a7e1e21 100644 --- a/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java +++ b/bundles/org.openhab.transform.basicprofiles/src/test/java/org/openhab/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java @@ -12,24 +12,24 @@ */ package org.openhab.transform.basicprofiles.internal.profiles; -import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -import static org.mockito.Mockito.reset; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import java.util.Collection; import java.util.Hashtable; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; +import javax.measure.MetricPrefix; +import javax.measure.Quantity; +import javax.measure.Unit; import javax.measure.quantity.Dimensionless; import javax.measure.quantity.Power; +import javax.measure.quantity.Time; +import javax.measure.spi.SystemOfUnits; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.BeforeEach; @@ -63,6 +63,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.ImperialUnits; import org.openhab.core.library.unit.SIUnits; import org.openhab.core.library.unit.Units; import org.openhab.core.thing.link.ItemChannelLink; @@ -106,7 +107,7 @@ public void setup() throws ItemNotFoundException { reset(mockCallback); reset(mockItemChannelLink); when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink); - when(mockItemRegistry.getItem("")).thenThrow(ItemNotFoundException.class); + // when(mockItemRegistry.getItem("")).thenThrow(ItemNotFoundException.class); } @Test @@ -291,7 +292,7 @@ public static Stream testComparingItemWithValue() { ContactItem contactItem = new ContactItem("contactItem"); 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"); StringType s_foo = StringType.valueOf("foo"); StringType s_NULL = StringType.valueOf("NULL"); @@ -497,9 +498,9 @@ public static Stream testComparingItemWithOtherItem() { ContactItem contactItem = new ContactItem("contactItem"); ContactItem contactItem2 = new ContactItem("contactItem2"); - QuantityType q_1500W = QuantityType.valueOf("1500 W"); - QuantityType q_1_5kW = QuantityType.valueOf("1.5 kW"); - QuantityType q_10kW = QuantityType.valueOf("10 kW"); + QuantityType q_1500W = QuantityType.valueOf("1500 W"); + QuantityType q_1_5kW = QuantityType.valueOf("1.5 kW"); + QuantityType q_10kW = QuantityType.valueOf("10 kW"); DecimalType d_1500 = DecimalType.valueOf("1500"); DecimalType d_2000 = DecimalType.valueOf("2000"); @@ -575,7 +576,7 @@ public static Stream testComparingInputStateWithValue() { StringItem stringItem = new StringItem("ItemName"); DimmerItem dimmerItem = new DimmerItem("ItemName"); - QuantityType q_1500W = QuantityType.valueOf("1500 W"); + QuantityType q_1500W = QuantityType.valueOf("1500 W"); DecimalType d_1500 = DecimalType.valueOf("1500"); StringType s_foo = StringType.valueOf("foo"); @@ -664,7 +665,6 @@ public void testComparingInputStateWithItem(GenericItem linkedItem, State inputS profile.onStateUpdateFromHandler(inputState); reset(mockCallback); - when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink); item.setState(state); profile.onStateUpdateFromHandler(inputState); @@ -721,8 +721,8 @@ public static Stream testFunctions() { Arguments.of(decimalItem, "$DELTA_PERCENT < 10", decimals, DecimalType.valueOf("0.91"), true), // Arguments.of(decimalItem, "$DELTA_PERCENT < 10", decimals, DecimalType.valueOf("0.89"), false), // - Arguments.of(decimalItem, "$DELTA_PERCENT < 10", negativeDecimals, DecimalType.valueOf("0"), false), - Arguments.of(decimalItem, "10 > $DELTA_PERCENT", negativeDecimals, DecimalType.valueOf("0"), false), + Arguments.of(decimalItem, "$DELTA_PERCENT < 10", negativeDecimals, DecimalType.valueOf("0"), false), // + Arguments.of(decimalItem, "10 > $DELTA_PERCENT", negativeDecimals, DecimalType.valueOf("0"), false), // Arguments.of(decimalItem, "< 10%", decimals, DecimalType.valueOf("1.09"), true), // Arguments.of(decimalItem, "< 10%", decimals, DecimalType.valueOf("1.11"), false), // @@ -868,7 +868,6 @@ private void internalTestFunctions(Item item, String condition, List stat } reset(mockCallback); - when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink); profile.onStateUpdateFromHandler(input); verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(input); @@ -899,4 +898,178 @@ public void testFirstDataIsAcceptedForDeltaFunctions(String conditions) throws I profile.onStateUpdateFromHandler(DecimalType.valueOf("1")); verify(mockCallback, times(1)).sendUpdate(DecimalType.valueOf("1")); } + + public static Stream testMixedStates() { + NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER); + + List states = List.of( // + UnDefType.UNDEF, // + QuantityType.valueOf(99, SIUnits.METRE), // + QuantityType.valueOf(1, Units.WATT), // + DecimalType.valueOf("2"), // + QuantityType.valueOf(2000, MetricPrefix.MILLI(Units.WATT)), // + QuantityType.valueOf(3, Units.WATT)); // + + return Stream.of( + // average function (true) + Arguments.of(powerItem, "== $AVG", states, QuantityType.valueOf("2 W"), true), + Arguments.of(powerItem, "== $AVG", states, QuantityType.valueOf("2000 mW"), true), + Arguments.of(powerItem, "== $AVERAGE", states, QuantityType.valueOf("0.002 kW"), true), + Arguments.of(powerItem, "> $AVERAGE", states, QuantityType.valueOf("3 W"), true), + + // average function (false) + Arguments.of(powerItem, "> $AVERAGE", states, QuantityType.valueOf("2 W"), false), + Arguments.of(powerItem, "== $AVERAGE", states, DecimalType.valueOf("2"), false), + + // min function (true) + Arguments.of(powerItem, "== $MIN", states, QuantityType.valueOf("1 W"), true), + Arguments.of(powerItem, "== $MIN", states, QuantityType.valueOf("1000 mW"), true), + + // min function (false) + Arguments.of(powerItem, "== $MIN", states, DecimalType.valueOf("1"), false), + + // max function (true) + Arguments.of(powerItem, "== $MAX", states, QuantityType.valueOf("3 W"), true), + Arguments.of(powerItem, "== $MAX", states, QuantityType.valueOf("0.003 kW"), true), + + // max function (false) + Arguments.of(powerItem, "== $MAX", states, DecimalType.valueOf("1"), false), + + // delta function (true) + Arguments.of(powerItem, "$DELTA <= 1 W", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "$DELTA > 0.5 W", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "$DELTA > 0.0005 kW", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "0.5 W < $DELTA", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "500 mW < $DELTA", states, QuantityType.valueOf("4 W"), true), + + // delta function (false) + Arguments.of(powerItem, "$DELTA > 0.5 W", states, QuantityType.valueOf("3.4 W"), false), + Arguments.of(powerItem, "$DELTA > 0.5", states, QuantityType.valueOf("4 W"), false), + + // delta percent function (true) + Arguments.of(powerItem, "$DELTA_PERCENT > 30", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "30 < $DELTA_PERCENT", states, QuantityType.valueOf("4 W"), true), + + // delta percent function (false) + Arguments.of(powerItem, "$DELTA_PERCENT > 310", states, QuantityType.valueOf("4 W"), false), + Arguments.of(powerItem, "310 < $DELTA_PERCENT", states, QuantityType.valueOf("4 W"), false), + + // unit based comparisons (true) + Arguments.of(powerItem, "> 0.5 W", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "> 500 mW", states, QuantityType.valueOf("4 W"), true), + Arguments.of(powerItem, "> 0.0005 kW", states, QuantityType.valueOf("4 W"), true), + + // unit based comparisons (false) + Arguments.of(powerItem, "> 0.5 W", states, QuantityType.valueOf("0.4 W"), false), + Arguments.of(powerItem, "> 500 mW", states, QuantityType.valueOf("0.4 W"), false), + Arguments.of(powerItem, "> 0.0005 kW", states, QuantityType.valueOf("0.4 W"), false), + + // percent comparisons (true) + Arguments.of(powerItem, "> 30 %", states, QuantityType.valueOf("4 W"), true), + + // percent comparisons (false) + Arguments.of(powerItem, "> 310 %", states, QuantityType.valueOf("4 W"), false) // + ); + } + + @ParameterizedTest + @MethodSource + public void testMixedStates(Item item, String condition, List states, State input, boolean expected) + throws ItemNotFoundException { + internalTestFunctions(item, condition, states, input, expected); + } + + /** + * A {@link UnitProvider} that provides Units.MIRED + */ + protected static class MirekUnitProvider implements UnitProvider { + + @SuppressWarnings("unchecked") + @Override + public > Unit getUnit(Class dimension) throws IllegalArgumentException { + return (Unit) Units.MIRED; + } + + @Override + public SystemOfUnits getMeasurementSystem() { + return SIUnits.getInstance(); + } + + @Override + public Collection>> getAllDimensions() { + return Set.of(); + } + } + + public static Stream testColorTemperatureValues() { + NumberItem kelvinItem = new NumberItem("Number:Temperature", "kelvinItem", UNIT_PROVIDER); + NumberItem mirekItem = new NumberItem("Number:Temperature", "mirekItem", new MirekUnitProvider()); + + List states = List.of( // + QuantityType.valueOf(500, Units.MIRED), // + QuantityType.valueOf(2000 + (1 * 100), Units.KELVIN), // + QuantityType.valueOf(1726.85 + (2 * 100), SIUnits.CELSIUS), // + QuantityType.valueOf(3140.33 + (3 * 180), ImperialUnits.FAHRENHEIT)); + + return Stream.of( // + // kelvin based item + Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf("2000 K"), true), + Arguments.of(kelvinItem, "== $MAX", states, QuantityType.valueOf("2300 K"), true), + Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf(500, Units.MIRED), true), + Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf(1726.85, SIUnits.CELSIUS), true), + Arguments.of(kelvinItem, "== $MIN", states, QuantityType.valueOf(3140.33, ImperialUnits.FAHRENHEIT), + true), + + // kelvin based item average (note: actual is 2150) + Arguments.of(kelvinItem, "<= $AVG", states, QuantityType.valueOf("2149 K"), true), + Arguments.of(kelvinItem, ">= $AVG", states, QuantityType.valueOf("2151 K"), true), + + // mirek based item (note: min and max are reversed + Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf("2000 K"), true), + Arguments.of(mirekItem, "== $MIN", states, QuantityType.valueOf("2300 K"), true), + Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf(500, Units.MIRED), true), + Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf(1726.85, SIUnits.CELSIUS), true), + Arguments.of(mirekItem, "== $MAX", states, QuantityType.valueOf(3140.33, ImperialUnits.FAHRENHEIT), + true), + + // mirek based item average (note: actual is 466.37) + Arguments.of(mirekItem, "<= $AVG", states, QuantityType.valueOf(466, Units.MIRED), true), + Arguments.of(mirekItem, ">= $AVG", states, QuantityType.valueOf(468, Units.MIRED), true) // + ); + } + + @ParameterizedTest + @MethodSource + public void testColorTemperatureValues(Item item, String condition, List states, State input, + boolean expected) throws ItemNotFoundException { + internalTestFunctions(item, condition, states, input, expected); + } + + public static Stream testTimeValues() { + NumberItem timeItem = new NumberItem("Number:Time", "timeItem", UNIT_PROVIDER); + + QuantityType