*/
package org.openhab.transform.basicprofiles.internal.profiles;
+import static java.util.function.Predicate.not;
import static org.openhab.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID;
import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.Locale;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
+import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.TypeParser;
+import org.openhab.core.types.UnDefType;
import org.openhab.transform.basicprofiles.internal.config.StateFilterProfileConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
* met.
*
* @author Arne Seime - Initial contribution
+ * @author Jimmy Tanagra - Expanded the comparison types
*/
@NonNullByDefault
public class StateFilterProfile implements StateProfile {
+ private final static String OPERATOR_NAME_PATTERN = Stream.of(StateCondition.ComparisonType.values())
+ .map(StateCondition.ComparisonType::name)
+ // We want to match the longest operator first, e.g. `GTE` before `GT`
+ .sorted(Comparator.comparingInt(String::length).reversed())
+ // Require a leading space only when it is preceded by a non-space character, e.g. `Item1 GTE 0`
+ // so we can have conditions against input data without needing a leading space, e.g. `GTE 0`
+ .collect(Collectors.joining("|", "(?:(?<=\\S)\\s+|^\\s*)(?:", ")\\s"));
+
+ private final static String OPERATOR_SYMBOL_PATTERN = Stream.of(StateCondition.ComparisonType.values())
+ .map(StateCondition.ComparisonType::symbol)
+ // We want to match the longest operator first, e.g. `<=` before `<`
+ .sorted(Comparator.comparingInt(String::length).reversed()) //
+ .collect(Collectors.joining("|", "(?:", ")"));
+
+ private final static Pattern EXPRESSION_PATTERN = Pattern.compile(
+ // - Without the non-greedy operator in the first capture group,
+ // it will match `Item<` when encountering `Item<>X` condition
+ // - Symbols may be more prevalently used, so check them first
+ "(.*?)(" + OPERATOR_SYMBOL_PATTERN + "|" + OPERATOR_NAME_PATTERN + ")(.*)", Pattern.CASE_INSENSITIVE);
+
private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class);
- private final ItemRegistry itemRegistry;
private final ProfileCallback callback;
- private List<Class<? extends State>> acceptedDataTypes;
- private List<StateCondition> conditions = List.of();
+ private final ItemRegistry itemRegistry;
+
+ private final List<StateCondition> conditions;
- private @Nullable State configMismatchState = null;
+ private final @Nullable State configMismatchState;
public StateFilterProfile(ProfileCallback callback, ProfileContext context, ItemRegistry itemRegistry) {
this.callback = callback;
- acceptedDataTypes = context.getAcceptedDataTypes();
this.itemRegistry = itemRegistry;
StateFilterProfileConfig config = context.getConfiguration().as(StateFilterProfileConfig.class);
if (config != null) {
conditions = parseConditions(config.conditions, config.separator);
- configMismatchState = parseState(config.mismatchState);
+ if (conditions.isEmpty()) {
+ logger.warn("No valid conditions defined for StateFilterProfile. Link: {}. Conditions: {}",
+ callback.getItemChannelLink(), config.conditions);
+ }
+ configMismatchState = parseState(config.mismatchState, context.getAcceptedDataTypes());
+ } else {
+ conditions = List.of();
+ configMismatchState = null;
}
}
- private List<StateCondition> parseConditions(@Nullable String config, String separator) {
- if (config == null) {
- return List.of();
- }
-
+ private List<StateCondition> parseConditions(List<String> conditions, String separator) {
List<StateCondition> parsedConditions = new ArrayList<>();
- try {
- String[] expressions = config.split(separator);
- for (String expression : expressions) {
- String[] parts = expression.trim().split("\s");
- if (parts.length == 3) {
- String itemName = parts[0];
- StateCondition.ComparisonType conditionType = StateCondition.ComparisonType
- .valueOf(parts[1].toUpperCase(Locale.ROOT));
- String value = parts[2];
- parsedConditions.add(new StateCondition(itemName, conditionType, value));
- } else {
- logger.warn("Malformed condition expression: '{}'", expression);
- }
- }
- return parsedConditions;
- } catch (IllegalArgumentException e) {
- logger.warn("Cannot parse condition {}. Expected format ITEM_NAME <EQ|NEQ> STATE_VALUE: '{}'", config,
- e.getMessage());
- return List.of();
- }
+ conditions.stream() //
+ .flatMap(c -> Stream.of(c.split(separator))) //
+ .map(String::trim) //
+ .filter(not(String::isBlank)) //
+ .forEach(expression -> {
+ Matcher matcher = EXPRESSION_PATTERN.matcher(expression);
+ if (!matcher.matches()) {
+ logger.warn(
+ "Malformed condition expression: '{}' in link '{}'. Expected format ITEM_NAME OPERATOR ITEM_OR_STATE, where OPERATOR is one of: {}",
+ expression, callback.getItemChannelLink(),
+ StateCondition.ComparisonType.namesAndSymbols());
+ return;
+ }
+
+ String itemName = matcher.group(1).trim();
+ String operator = matcher.group(2).trim();
+ String value = matcher.group(3).trim();
+ try {
+ StateCondition.ComparisonType comparisonType = StateCondition.ComparisonType
+ .fromSymbol(operator).orElseGet(
+ () -> StateCondition.ComparisonType.valueOf(operator.toUpperCase(Locale.ROOT)));
+ parsedConditions.add(new StateCondition(itemName, comparisonType, value));
+ } catch (IllegalArgumentException e) {
+ logger.warn("Invalid comparison operator: '{}' in link '{}'. Expected one of: {}", operator,
+ callback.getItemChannelLink(), StateCondition.ComparisonType.namesAndSymbols());
+ }
+ });
+
+ return parsedConditions;
}
@Override
@Nullable
private State checkCondition(State state) {
- if (!conditions.isEmpty()) {
- boolean allConditionsMet = true;
- for (StateCondition condition : conditions) {
- logger.debug("Evaluting condition: {}", condition);
- try {
- Item item = itemRegistry.getItem(condition.itemName);
- String itemState = item.getState().toString();
-
- if (!condition.matches(itemState)) {
- allConditionsMet = false;
- }
- } catch (ItemNotFoundException e) {
- logger.warn(
- "Cannot find item '{}' in registry - check your condition expression - skipping state update",
- condition.itemName);
- allConditionsMet = false;
- }
-
- }
- if (allConditionsMet) {
- return state;
- } else {
- return configMismatchState;
- }
- } else {
+ if (conditions.isEmpty()) {
logger.warn(
- "No configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update");
+ "No valid configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update. Link: '{}'",
+ callback.getItemChannelLink());
+ return null;
}
- return null;
+ String linkedItemName = callback.getItemChannelLink().getItemName();
+
+ if (conditions.stream().allMatch(c -> c.check(linkedItemName, state))) {
+ return state;
+ } else {
+ return configMismatchState;
+ }
}
@Nullable
- State parseState(@Nullable String stateString) {
+ static State parseState(@Nullable String stateString, List<Class<? extends State>> acceptedDataTypes) {
// Quoted strings are parsed as StringType
if (stateString == null) {
return null;
}
class StateCondition {
- String itemName;
-
- ComparisonType comparisonType;
- String value;
-
- boolean quoted = false;
+ private String itemName;
+ private ComparisonType comparisonType;
+ private String value;
+ private @Nullable State parsedValue;
public StateCondition(String itemName, ComparisonType comparisonType, String value) {
this.itemName = itemName;
this.comparisonType = comparisonType;
this.value = value;
- this.quoted = value.startsWith("'") && value.endsWith("'");
- if (quoted) {
- this.value = value.substring(1, value.length() - 1);
- }
+ // Convert quoted strings to StringType, and UnDefTypes to UnDefType
+ // UnDefType gets special treatment because we don't want `UNDEF` to be parsed as a string
+ // Anything else, defer parsing until we're checking the condition
+ // so we can try based on the item's accepted data types
+ this.parsedValue = parseState(value, List.of(UnDefType.class));
}
- public boolean matches(String state) {
- switch (comparisonType) {
- case EQ:
- return state.equals(value);
- case NEQ: {
- return !state.equals(value);
+ /**
+ * Check if the condition is met.
+ *
+ * If the itemName is not empty, the condition is checked against the item's state.
+ * Otherwise, the condition is checked against the input state.
+ *
+ * @param input the state to check against
+ * @return true if the condition is met, false otherwise
+ */
+ public boolean check(String linkedItemName, State input) {
+ try {
+ State state;
+ Item item = null;
+
+ if (logger.isDebugEnabled()) {
+ logger.debug("Evaluating {} with input: {} ({}). Link: '{}'", this, input,
+ input.getClass().getSimpleName(), callback.getItemChannelLink());
+ }
+ if (itemName.isEmpty()) {
+ item = itemRegistry.getItem(linkedItemName);
+ state = input;
+ } else {
+ item = itemRegistry.getItem(itemName);
+ state = item.getState();
+ }
+
+ // Using Object because we could be comparing State or String objects
+ Object lhs;
+ Object rhs;
+
+ // Java Enums (e.g. OnOffType) are Comparable, but we want to treat them as not Comparable
+ if (state instanceof Comparable && !(state instanceof Enum)) {
+ lhs = state;
+ } else {
+ // Only allow EQ and NEQ for non-comparable states
+ if (!(comparisonType == ComparisonType.EQ || comparisonType == ComparisonType.NEQ
+ || comparisonType == ComparisonType.NEQ_ALT)) {
+ logger.debug("Condition state: '{}' ({}) only supports '==' and '!==' comparisons", state,
+ state.getClass().getSimpleName());
+ return false;
+ }
+ lhs = state instanceof Enum ? state : state.toString();
+ }
+
+ if (parsedValue == null) {
+ // don't parse bare strings as StringType, because they are identifiers,
+ // e.g. referring to other items
+ List<Class<? extends State>> acceptedValueTypes = item.getAcceptedDataTypes().stream()
+ .filter(not(StringType.class::isAssignableFrom)).toList();
+ parsedValue = TypeParser.parseState(acceptedValueTypes, value);
+ // Don't convert QuantityType to other types, so that 1500 != 1500 W
+ if (parsedValue != null && !(parsedValue instanceof QuantityType)) {
+ // Try to convert it to the same type as the state
+ // This allows comparing compatible types, e.g. PercentType vs OnOffType
+ parsedValue = parsedValue.as(state.getClass());
+ }
+
+ // If the values can't be converted to a type, check to see if it's an Item name
+ if (parsedValue == null) {
+ try {
+ Item valueItem = itemRegistry.getItem(value);
+ if (valueItem != null) { // ItemRegistry.getItem can return null in tests
+ parsedValue = valueItem.getState();
+ // Don't convert QuantityType to other types
+ if (!(parsedValue instanceof QuantityType)) {
+ parsedValue = parsedValue.as(state.getClass());
+ }
+ logger.debug("Condition value: '{}' is an item state: '{}' ({})", value, parsedValue,
+ parsedValue == null ? "null" : parsedValue.getClass().getSimpleName());
+ }
+ } catch (ItemNotFoundException ignore) {
+ }
+ }
+
+ if (parsedValue == null) {
+ if (comparisonType == ComparisonType.NEQ || comparisonType == ComparisonType.NEQ_ALT) {
+ // They're not even type compatible, so return true for NEQ comparison
+ return true;
+ } else {
+ logger.debug("Condition value: '{}' is not compatible with state '{}' ({})", value, state,
+ state.getClass().getSimpleName());
+ return false;
+ }
+ }
+ }
+
+ rhs = Objects.requireNonNull(parsedValue instanceof StringType ? parsedValue.toString() : parsedValue);
+
+ if (logger.isDebugEnabled()) {
+ if (itemName.isEmpty()) {
+ logger.debug("Performing a comparison between input '{}' ({}) and value '{}' ({})", lhs,
+ lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
+ } else {
+ logger.debug("Performing a comparison between item '{}' state '{}' ({}) and value '{}' ({})",
+ itemName, lhs, lhs.getClass().getSimpleName(), rhs, rhs.getClass().getSimpleName());
+ }
}
- default:
- logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update",
- comparisonType);
- return false;
+ return switch (comparisonType) {
+ case EQ -> lhs.equals(rhs);
+ case NEQ, NEQ_ALT -> !lhs.equals(rhs);
+ case GT -> ((Comparable) lhs).compareTo(rhs) > 0;
+ case GTE -> ((Comparable) lhs).compareTo(rhs) >= 0;
+ case LT -> ((Comparable) lhs).compareTo(rhs) < 0;
+ case LTE -> ((Comparable) lhs).compareTo(rhs) <= 0;
+ };
+ } catch (ItemNotFoundException | IllegalArgumentException | ClassCastException e) {
+ logger.warn("Error evaluating condition: {} in link '{}': {}", this, callback.getItemChannelLink(),
+ e.getMessage());
}
+ return false;
}
enum ComparisonType {
- EQ,
- NEQ
+ EQ("=="),
+ NEQ("!="),
+ NEQ_ALT("<>"),
+ GT(">"),
+ GTE(">="),
+ LT("<"),
+ LTE("<=");
+
+ private final String symbol;
+
+ ComparisonType(String symbol) {
+ this.symbol = symbol;
+ }
+
+ String symbol() {
+ return symbol;
+ }
+
+ static Optional<ComparisonType> fromSymbol(String symbol) {
+ for (ComparisonType type : values()) {
+ if (type.symbol.equals(symbol)) {
+ return Optional.of(type);
+ }
+ }
+ return Optional.empty();
+ }
+
+ static List<String> namesAndSymbols() {
+ return Stream.of(values()).flatMap(entry -> Stream.of(entry.name(), entry.symbol())).toList();
+ }
}
@Override
public String toString() {
- return "Condition{itemName='" + itemName + "', comparisonType=" + comparisonType + ", value='" + value
- + "'}'";
+ Object state = null;
+
+ try {
+ state = itemRegistry.getItem(itemName).getState();
+ } catch (ItemNotFoundException ignored) {
+ }
+
+ String stateClass = state == null ? "null" : state.getClass().getSimpleName();
+ return "Condition(itemName='" + itemName + "', state='" + state + "' (" + stateClass + "), comparisonType="
+ + comparisonType + ", value='" + value + "')";
}
}
}
*/
package org.openhab.transform.basicprofiles.internal.profiles;
+import static org.hamcrest.Matchers.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import java.util.Hashtable;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
+import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.openhab.core.config.core.Configuration;
+import org.openhab.core.i18n.UnitProvider;
+import org.openhab.core.internal.i18n.I18nProviderImpl;
+import org.openhab.core.items.GenericItem;
import org.openhab.core.items.Item;
import org.openhab.core.items.ItemNotFoundException;
import org.openhab.core.items.ItemRegistry;
-import org.openhab.core.library.items.StringItem;
-import org.openhab.core.library.types.OnOffType;
-import org.openhab.core.library.types.StringType;
+import org.openhab.core.library.items.*;
+import org.openhab.core.library.types.*;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.thing.link.ItemChannelLink;
import org.openhab.core.thing.profiles.ProfileCallback;
import org.openhab.core.thing.profiles.ProfileContext;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
+import org.osgi.framework.BundleContext;
+import org.osgi.service.component.ComponentContext;
/**
* Basic unit tests for {@link StateFilterProfile}.
private @Mock @NonNullByDefault({}) ProfileCallback mockCallback;
private @Mock @NonNullByDefault({}) ProfileContext mockContext;
private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry;
+ private @Mock @NonNullByDefault({}) ItemChannelLink mockItemChannelLink;
+
+ private static final UnitProvider UNIT_PROVIDER;
+
+ static {
+ ComponentContext context = Mockito.mock(ComponentContext.class);
+ BundleContext bundleContext = Mockito.mock(BundleContext.class);
+ Hashtable<String, Object> properties = new Hashtable<>();
+ properties.put("measurementSystem", SIUnits.MEASUREMENT_SYSTEM_NAME);
+ when(context.getProperties()).thenReturn(properties);
+ when(context.getBundleContext()).thenReturn(bundleContext);
+ UNIT_PROVIDER = new I18nProviderImpl(context);
+ }
@BeforeEach
- public void setup() {
+ public void setup() throws ItemNotFoundException {
reset(mockContext);
reset(mockCallback);
+ reset(mockItemChannelLink);
+ when(mockCallback.getItemChannelLink()).thenReturn(mockItemChannelLink);
+ when(mockItemRegistry.getItem("")).thenThrow(ItemNotFoundException.class);
}
@Test
@Test
public void testInvalidComparatorConditions() throws ItemNotFoundException {
- when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName lt Value")));
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName is Value")));
StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
- when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class);
+ when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
State expectation = OnOffType.ON;
profile.onStateUpdateFromHandler(expectation);
@Test
public void testSingleConditionMatch() throws ItemNotFoundException {
- when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value")));
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq 'Value'")));
when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
return item;
}
+ private Item numberItemWithState(String itemType, String itemName, State value) {
+ NumberItem item = new NumberItem(itemType, itemName, null);
+ item.setState(value);
+ return item;
+ }
+
@Test
public void testMultipleCondition_AllMatch() throws ItemNotFoundException {
when(mockContext.getConfiguration())
- .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2")));
+ .thenReturn(new Configuration(Map.of("conditions", "ItemName eq 'Value', ItemName2 eq 'Value2'")));
when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value"));
when(mockItemRegistry.getItem("ItemName2")).thenReturn(stringItemWithState("ItemName2", "Value2"));
@Test
void testParseStateNonQuotes() {
- when(mockContext.getAcceptedDataTypes())
- .thenReturn(List.of(UnDefType.class, OnOffType.class, StringType.class));
+ List<Class<? extends State>> acceptedDataTypes = List.of(UnDefType.class, OnOffType.class, StringType.class);
+
+ when(mockContext.getAcceptedDataTypes()).thenReturn(acceptedDataTypes);
when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "")));
StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
- assertEquals(UnDefType.UNDEF, profile.parseState("UNDEF"));
- assertEquals(new StringType("UNDEF"), profile.parseState("'UNDEF'"));
- assertEquals(OnOffType.ON, profile.parseState("ON"));
- assertEquals(new StringType("ON"), profile.parseState("'ON'"));
+ assertEquals(UnDefType.UNDEF, profile.parseState("UNDEF", acceptedDataTypes));
+ assertEquals(new StringType("UNDEF"), profile.parseState("'UNDEF'", acceptedDataTypes));
+ assertEquals(OnOffType.ON, profile.parseState("ON", acceptedDataTypes));
+ assertEquals(new StringType("ON"), profile.parseState("'ON'", acceptedDataTypes));
+ }
+
+ public static Stream<Arguments> testComparingItemWithValue() {
+ NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER);
+ NumberItem decimalItem = new NumberItem("ItemName");
+ StringItem stringItem = new StringItem("ItemName");
+ SwitchItem switchItem = new SwitchItem("ItemName");
+ DimmerItem dimmerItem = new DimmerItem("ItemName");
+ ContactItem contactItem = new ContactItem("ItemName");
+ RollershutterItem rollershutterItem = new RollershutterItem("ItemName");
+
+ QuantityType q_1500W = QuantityType.valueOf("1500 W");
+ DecimalType d_1500 = DecimalType.valueOf("1500");
+ StringType s_foo = StringType.valueOf("foo");
+ StringType s_NULL = StringType.valueOf("NULL");
+ StringType s_UNDEF = StringType.valueOf("UNDEF");
+ StringType s_OPEN = StringType.valueOf("OPEN");
+
+ return Stream.of( //
+ // We should be able to check item state is/isn't UNDEF/NULL
+
+ // First, when the item state is actually an UnDefType
+ // An unquoted value UNDEF/NULL should be treated as an UnDefType
+ // Only equality comparisons against the matching UnDefType will return true
+ // Any other comparisons should return false
+ Arguments.of(stringItem, UnDefType.UNDEF, "==", "UNDEF", true), //
+ Arguments.of(dimmerItem, UnDefType.UNDEF, "==", "UNDEF", true), //
+ Arguments.of(dimmerItem, UnDefType.NULL, "==", "NULL", true), //
+ Arguments.of(dimmerItem, UnDefType.NULL, "==", "UNDEF", false), //
+ Arguments.of(decimalItem, UnDefType.NULL, ">", "10", false), //
+ Arguments.of(decimalItem, UnDefType.NULL, "<", "10", false), //
+ Arguments.of(decimalItem, UnDefType.NULL, "==", "10", false), //
+ Arguments.of(decimalItem, UnDefType.NULL, ">=", "10", false), //
+ Arguments.of(decimalItem, UnDefType.NULL, "<=", "10", false), //
+
+ // A quoted value (String) isn't UnDefType
+ Arguments.of(stringItem, UnDefType.UNDEF, "==", "'UNDEF'", false), //
+ Arguments.of(stringItem, UnDefType.UNDEF, "!=", "'UNDEF'", true), //
+ Arguments.of(stringItem, UnDefType.NULL, "==", "'NULL'", false), //
+ Arguments.of(stringItem, UnDefType.NULL, "!=", "'NULL'", true), //
+
+ // When the item state is not an UnDefType
+ // UnDefType is special. When unquoted and comparing against a StringItem,
+ // don't treat it as a string
+ Arguments.of(stringItem, s_NULL, "==", "'NULL'", true), // Comparing String to String
+ Arguments.of(stringItem, s_NULL, "==", "NULL", false), // String state != UnDefType
+ Arguments.of(stringItem, s_NULL, "!=", "NULL", true), //
+ Arguments.of(stringItem, s_UNDEF, "==", "'UNDEF'", true), // Comparing String to String
+ Arguments.of(stringItem, s_UNDEF, "==", "UNDEF", false), // String state != UnDefType
+ Arguments.of(stringItem, s_UNDEF, "!=", "UNDEF", true), //
+
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "UNDEF", false), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "UNDEF", true), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "NULL", false), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "NULL", true), //
+
+ // Check for OPEN/CLOSED
+ Arguments.of(contactItem, OpenClosedType.OPEN, "==", "OPEN", true), //
+ Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'OPEN'", true), // String != Enum
+ Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "CLOSED", true), //
+ Arguments.of(contactItem, OpenClosedType.OPEN, "==", "CLOSED", false), //
+ Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "CLOSED", true), //
+ Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "OPEN", true), //
+
+ // ON/OFF
+ Arguments.of(switchItem, OnOffType.ON, "==", "ON", true), //
+ Arguments.of(switchItem, OnOffType.ON, "!=", "ON", false), //
+ Arguments.of(switchItem, OnOffType.ON, "!=", "OFF", true), //
+ Arguments.of(switchItem, OnOffType.ON, "!=", "UNDEF", true), //
+ Arguments.of(switchItem, UnDefType.UNDEF, "==", "UNDEF", true), //
+ Arguments.of(switchItem, OnOffType.ON, "==", "'ON'", false), // it's not a string
+ Arguments.of(switchItem, OnOffType.ON, "!=", "'ON'", true), // incompatible types
+
+ // Enum types != String
+ Arguments.of(contactItem, OpenClosedType.OPEN, "==", "'OPEN'", false), //
+ Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'CLOSED'", true), //
+ Arguments.of(contactItem, OpenClosedType.OPEN, "!=", "'OPEN'", true), //
+ Arguments.of(contactItem, OpenClosedType.OPEN, "==", "'CLOSED'", false), //
+ Arguments.of(contactItem, OpenClosedType.CLOSED, "==", "'CLOSED'", false), //
+ Arguments.of(contactItem, OpenClosedType.CLOSED, "!=", "'CLOSED'", true), //
+
+ // non UnDefType checks
+ // String constants must be quoted
+ Arguments.of(stringItem, s_foo, "==", "'foo'", true), //
+ Arguments.of(stringItem, s_foo, "==", "foo", false), //
+ Arguments.of(stringItem, s_foo, "!=", "foo", true), // not quoted -> not a string
+ Arguments.of(stringItem, s_foo, "<>", "foo", true), //
+ Arguments.of(stringItem, s_foo, " <>", "foo", true), //
+ Arguments.of(stringItem, s_foo, "<> ", "foo", true), //
+ Arguments.of(stringItem, s_foo, " <> ", "foo", true), //
+ Arguments.of(stringItem, s_foo, "!=", "'foo'", false), //
+ Arguments.of(stringItem, s_foo, "<>", "'foo'", false), //
+ Arguments.of(stringItem, s_foo, " <>", "'foo'", false), //
+
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "100", true), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, ">=", "100", true), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, ">", "50", true), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, ">=", "50", true), //
+ Arguments.of(dimmerItem, PercentType.ZERO, "<", "50", true), //
+ Arguments.of(dimmerItem, PercentType.ZERO, ">=", "50", false), //
+ Arguments.of(dimmerItem, PercentType.ZERO, ">=", "0", true), //
+ Arguments.of(dimmerItem, PercentType.ZERO, "<", "0", false), //
+ Arguments.of(dimmerItem, PercentType.ZERO, "<=", "0", true), //
+
+ // Numeric vs Strings aren't comparable
+ Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "'100'", false), //
+ Arguments.of(rollershutterItem, PercentType.HUNDRED, "!=", "'100'", true), //
+ Arguments.of(rollershutterItem, PercentType.HUNDRED, ">", "'10'", false), //
+ Arguments.of(powerItem, q_1500W, "==", "'1500 W'", false), // QuantityType vs String => fail
+ Arguments.of(decimalItem, d_1500, "==", "'1500'", false), //
+
+ // Compatible type castings are supported
+ Arguments.of(dimmerItem, PercentType.ZERO, "==", "OFF", true), //
+ Arguments.of(dimmerItem, PercentType.ZERO, "==", "ON", false), //
+ Arguments.of(dimmerItem, PercentType.ZERO, "!=", "ON", true), //
+ Arguments.of(dimmerItem, PercentType.ZERO, "!=", "OFF", false), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "ON", true), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "==", "OFF", false), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "ON", false), //
+ Arguments.of(dimmerItem, PercentType.HUNDRED, "!=", "OFF", true), //
+
+ // UpDownType gets converted to PercentType for comparison
+ Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "DOWN", true), //
+ Arguments.of(rollershutterItem, PercentType.HUNDRED, "==", "UP", false), //
+ Arguments.of(rollershutterItem, PercentType.HUNDRED, "!=", "UP", true), //
+ Arguments.of(rollershutterItem, PercentType.ZERO, "==", "UP", true), //
+ Arguments.of(rollershutterItem, PercentType.ZERO, "!=", "DOWN", true), //
+
+ Arguments.of(decimalItem, d_1500, " eq ", "1500", true), //
+ Arguments.of(decimalItem, d_1500, " eq ", "1500", true), //
+ Arguments.of(decimalItem, d_1500, "==", "1500", true), //
+ Arguments.of(decimalItem, d_1500, " ==", "1500", true), //
+ Arguments.of(decimalItem, d_1500, "== ", "1500", true), //
+ Arguments.of(decimalItem, d_1500, " == ", "1500", true), //
+
+ Arguments.of(powerItem, q_1500W, " eq ", "1500", false), // no unit => fail
+ Arguments.of(powerItem, q_1500W, "==", "1500", false), // no unit => fail
+ Arguments.of(powerItem, q_1500W, " eq ", "1500 cm", false), // wrong unit
+ Arguments.of(powerItem, q_1500W, "==", "1500 cm", false), // wrong unit
+
+ Arguments.of(powerItem, q_1500W, " eq ", "1500 W", true), //
+ Arguments.of(powerItem, q_1500W, " eq ", "1.5 kW", true), //
+ Arguments.of(powerItem, q_1500W, " eq ", "2 kW", false), //
+ Arguments.of(powerItem, q_1500W, "==", "1500 W", true), //
+ Arguments.of(powerItem, q_1500W, "==", "1.5 kW", true), //
+ Arguments.of(powerItem, q_1500W, "==", "2 kW", false), //
+
+ Arguments.of(powerItem, q_1500W, " neq ", "500 W", true), //
+ Arguments.of(powerItem, q_1500W, " neq ", "1500", true), // Not the same type, so not equal
+ Arguments.of(powerItem, q_1500W, " neq ", "1500 W", false), //
+ Arguments.of(powerItem, q_1500W, " neq ", "1.5 kW", false), //
+ Arguments.of(powerItem, q_1500W, "!=", "500 W", true), //
+ Arguments.of(powerItem, q_1500W, "!=", "1500", true), // not the same type
+ Arguments.of(powerItem, q_1500W, "!=", "1500 W", false), //
+ Arguments.of(powerItem, q_1500W, "!=", "1.5 kW", false), //
+
+ Arguments.of(powerItem, q_1500W, " GT ", "100 W", true), //
+ Arguments.of(powerItem, q_1500W, " GT ", "1 kW", true), //
+ Arguments.of(powerItem, q_1500W, " GT ", "2 kW", false), //
+ Arguments.of(powerItem, q_1500W, ">", "100 W", true), //
+ Arguments.of(powerItem, q_1500W, ">", "1 kW", true), //
+ Arguments.of(powerItem, q_1500W, ">", "2 kW", false), //
+ Arguments.of(powerItem, q_1500W, " GTE ", "1500 W", true), //
+ Arguments.of(powerItem, q_1500W, " GTE ", "1 kW", true), //
+ Arguments.of(powerItem, q_1500W, " GTE ", "1.5 kW", true), //
+ Arguments.of(powerItem, q_1500W, " GTE ", "2 kW", false), //
+ Arguments.of(powerItem, q_1500W, " GTE ", "2000 mW", true), //
+ Arguments.of(powerItem, q_1500W, " GTE ", "20", false), // no unit
+ Arguments.of(powerItem, q_1500W, ">=", "1.5 kW", true), //
+ Arguments.of(powerItem, q_1500W, ">=", "2 kW", false), //
+ Arguments.of(powerItem, q_1500W, " LT ", "2 kW", true), //
+ Arguments.of(powerItem, q_1500W, "<", "2 kW", true), //
+ Arguments.of(powerItem, q_1500W, " LTE ", "2 kW", true), //
+ Arguments.of(powerItem, q_1500W, "<=", "2 kW", true), //
+ Arguments.of(powerItem, q_1500W, "<=", "1 kW", false), //
+ Arguments.of(powerItem, q_1500W, " LTE ", "1.5 kW", true), //
+ Arguments.of(powerItem, q_1500W, "<=", "1.5 kW", true) //
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ public void testComparingItemWithValue(GenericItem item, State state, String operator, String value,
+ boolean expected) throws ItemNotFoundException {
+ String itemName = item.getName();
+ item.setState(state);
+
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", itemName + operator + value)));
+ when(mockItemRegistry.getItem(itemName)).thenReturn(item);
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State inputData = new StringType("NewValue");
+ profile.onStateUpdateFromHandler(inputData);
+ verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputData));
+ }
+
+ public static Stream<Arguments> testComparingItemWithOtherItem() {
+ NumberItem powerItem = new NumberItem("Number:Power", "powerItem", UNIT_PROVIDER);
+ NumberItem powerItem2 = new NumberItem("Number:Power", "powerItem2", UNIT_PROVIDER);
+ NumberItem decimalItem = new NumberItem("decimalItem");
+ NumberItem decimalItem2 = new NumberItem("decimalItem2");
+ StringItem stringItem = new StringItem("stringItem");
+ StringItem stringItem2 = new StringItem("stringItem2");
+ ContactItem contactItem = new ContactItem("contactItem");
+ ContactItem contactItem2 = new ContactItem("contactItem2");
+
+ QuantityType q_1500W = QuantityType.valueOf("1500 W");
+ QuantityType q_1_5kW = QuantityType.valueOf("1.5 kW");
+ QuantityType q_10kW = QuantityType.valueOf("10 kW");
+
+ DecimalType d_1500 = DecimalType.valueOf("1500");
+ DecimalType d_2000 = DecimalType.valueOf("2000");
+ StringType s_1500 = StringType.valueOf("1500");
+ StringType s_foo = StringType.valueOf("foo");
+ StringType s_NULL = StringType.valueOf("NULL");
+
+ return Stream.of( //
+ Arguments.of(stringItem, s_foo, "==", stringItem2, s_foo, true), //
+ Arguments.of(stringItem, s_foo, "!=", stringItem2, s_foo, false), //
+ Arguments.of(stringItem, s_foo, "==", stringItem2, s_NULL, false), //
+ Arguments.of(stringItem, s_foo, "!=", stringItem2, s_NULL, true), //
+
+ Arguments.of(decimalItem, d_1500, "==", decimalItem2, d_1500, true), //
+ Arguments.of(decimalItem, d_1500, "==", decimalItem2, d_1500, true), //
+
+ // UNDEF/NULL are equals regardless of item type
+ Arguments.of(decimalItem, UnDefType.UNDEF, "==", stringItem, UnDefType.UNDEF, true), //
+ Arguments.of(decimalItem, UnDefType.NULL, "==", stringItem, UnDefType.NULL, true), //
+ Arguments.of(decimalItem, UnDefType.NULL, "==", stringItem, UnDefType.UNDEF, false), //
+
+ Arguments.of(contactItem, OpenClosedType.OPEN, "==", contactItem2, OpenClosedType.OPEN, true), //
+ Arguments.of(contactItem, OpenClosedType.OPEN, "==", contactItem2, OpenClosedType.CLOSED, false), //
+
+ Arguments.of(decimalItem, d_1500, "==", decimalItem2, d_1500, true), //
+ Arguments.of(decimalItem, d_1500, "<", decimalItem2, d_2000, true), //
+ Arguments.of(decimalItem, d_1500, ">", decimalItem2, d_2000, false), //
+ Arguments.of(decimalItem, d_1500, ">", stringItem, s_1500, false), //
+ Arguments.of(powerItem, q_1500W, "<", powerItem2, q_10kW, true), //
+ Arguments.of(powerItem, q_1500W, ">", powerItem2, q_10kW, false), //
+ Arguments.of(powerItem, q_1500W, "==", powerItem2, q_1_5kW, true), //
+ Arguments.of(powerItem, q_1500W, ">=", powerItem2, q_1_5kW, true), //
+ Arguments.of(powerItem, q_1500W, ">", powerItem2, q_1_5kW, false), //
+
+ // Incompatible types
+ Arguments.of(decimalItem, d_1500, "==", stringItem, s_1500, false), //
+ Arguments.of(powerItem, q_1500W, "==", decimalItem, d_1500, false), // DecimalType != QuantityType
+ Arguments.of(decimalItem, d_1500, "==", powerItem, q_1500W, false) //
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ public void testComparingItemWithOtherItem(GenericItem item, State state, String operator, GenericItem item2,
+ State state2, boolean expected) throws ItemNotFoundException {
+ String itemName = item.getName();
+ item.setState(state);
+
+ String itemName2 = item2.getName();
+ item2.setState(state2);
+
+ if (item.equals(item2)) {
+ // For test writers:
+ // When using the same items, it doesn't make sense for their states to be different
+ assertEquals(state, state2);
+ }
+
+ when(mockContext.getConfiguration())
+ .thenReturn(new Configuration(Map.of("conditions", itemName + operator + itemName2)));
+ when(mockItemRegistry.getItem(itemName)).thenReturn(item);
+ when(mockItemRegistry.getItem(itemName2)).thenReturn(item2);
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ State inputData = new StringType("NewValue");
+ profile.onStateUpdateFromHandler(inputData);
+ verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputData));
+ }
+
+ public static Stream<Arguments> testComparingInputStateWithValue() {
+ NumberItem powerItem = new NumberItem("Number:Power", "ItemName", UNIT_PROVIDER);
+ NumberItem decimalItem = new NumberItem("ItemName");
+ StringItem stringItem = new StringItem("ItemName");
+ DimmerItem dimmerItem = new DimmerItem("ItemName");
+
+ QuantityType q_1500W = QuantityType.valueOf("1500 W");
+ DecimalType d_1500 = DecimalType.valueOf("1500");
+ StringType s_foo = StringType.valueOf("foo");
+
+ return Stream.of( //
+ // We should be able to check that input state is/isn't UNDEF/NULL
+
+ // First, when the input state is actually an UnDefType
+ // An unquoted value UNDEF/NULL should be treated as an UnDefType
+ Arguments.of(stringItem, UnDefType.UNDEF, "==", "UNDEF", true), //
+ Arguments.of(dimmerItem, UnDefType.NULL, "==", "NULL", true), //
+ Arguments.of(dimmerItem, UnDefType.NULL, "==", "UNDEF", false), //
+
+ // A quoted value (String) isn't UnDefType
+ Arguments.of(stringItem, UnDefType.UNDEF, "==", "'UNDEF'", false), //
+ Arguments.of(stringItem, UnDefType.UNDEF, "!=", "'UNDEF'", true), //
+ Arguments.of(stringItem, UnDefType.NULL, "==", "'NULL'", false), //
+ Arguments.of(stringItem, UnDefType.NULL, "!=", "'NULL'", true), //
+
+ // String values must be quoted
+ Arguments.of(stringItem, s_foo, "==", "'foo'", true), //
+ Arguments.of(stringItem, s_foo, "!=", "'foo'", false), //
+ Arguments.of(stringItem, s_foo, "==", "'bar'", false), //
+ // Unquoted string values are not compatible
+ // always returns false
+ Arguments.of(stringItem, s_foo, "==", "foo", false), //
+ Arguments.of(stringItem, s_foo, "!=", "foo", true), // not quoted -> not equal to string
+
+ Arguments.of(decimalItem, d_1500, "==", "1500", true), //
+ Arguments.of(decimalItem, d_1500, "!=", "1500", false), //
+ Arguments.of(decimalItem, d_1500, "==", "1000", false), //
+ Arguments.of(decimalItem, d_1500, "!=", "1000", true), //
+ Arguments.of(decimalItem, d_1500, ">", "1000", true), //
+ Arguments.of(decimalItem, d_1500, ">=", "1000", true), //
+ Arguments.of(decimalItem, d_1500, ">=", "1500", true), //
+ Arguments.of(decimalItem, d_1500, "<", "1600", true), //
+ Arguments.of(decimalItem, d_1500, "<=", "1600", true), //
+ Arguments.of(decimalItem, d_1500, "<", "1000", false), //
+ Arguments.of(decimalItem, d_1500, "<=", "1000", false), //
+ Arguments.of(decimalItem, d_1500, "<", "1500", false), //
+ Arguments.of(decimalItem, d_1500, "<=", "1500", true), //
+
+ // named operators - must have a trailing space
+ Arguments.of(decimalItem, d_1500, "LT ", "2000", true), //
+ Arguments.of(decimalItem, d_1500, "LTE ", "1500", true), //
+ Arguments.of(decimalItem, d_1500, " LTE ", "1500", true), //
+ Arguments.of(decimalItem, d_1500, " LTE ", "1500", true), //
+
+ Arguments.of(powerItem, q_1500W, "==", "1500 W", true), //
+ Arguments.of(powerItem, q_1500W, "==", "'1500 W'", false), // QuantityType != String
+ Arguments.of(powerItem, q_1500W, "==", "1.5 kW", true), //
+ Arguments.of(powerItem, q_1500W, ">", "2000 mW", true) //
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource
+ public void testComparingInputStateWithValue(GenericItem linkedItem, State inputState, String operator,
+ String value, boolean expected) throws ItemNotFoundException {
+
+ String linkedItemName = linkedItem.getName();
+
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", operator + value)));
+ when(mockItemRegistry.getItem(linkedItemName)).thenReturn(linkedItem);
+ when(mockItemChannelLink.getItemName()).thenReturn(linkedItemName);
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ profile.onStateUpdateFromHandler(inputState);
+ verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputState));
+ }
+
+ @ParameterizedTest
+ @MethodSource("testComparingItemWithOtherItem")
+ public void testComparingInputStateWithItem(GenericItem linkedItem, State inputState, String operator,
+ GenericItem item, State state, boolean expected) throws ItemNotFoundException {
+ String linkedItemName = linkedItem.getName();
+
+ String itemName = item.getName();
+ item.setState(state);
+
+ when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", operator + itemName)));
+ when(mockItemRegistry.getItem(itemName)).thenReturn(item);
+ when(mockItemRegistry.getItem(linkedItemName)).thenReturn(linkedItem);
+ when(mockItemChannelLink.getItemName()).thenReturn(linkedItemName);
+
+ StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry);
+
+ profile.onStateUpdateFromHandler(inputState);
+ verify(mockCallback, times(expected ? 1 : 0)).sendUpdate(eq(inputState));
}
}