From 6104110682cc9c1ea6a30ce1f7d08ad473addf3c Mon Sep 17 00:00:00 2001 From: Arne Seime Date: Sun, 24 Mar 2024 17:31:03 +0100 Subject: [PATCH] [basicprofiles] State filter profile (#531) * State filter profile Signed-off-by: Arne Seime Co-authored-by: J-N-K --- .../README.md | 31 +++ .../config/StateFilterProfileConfig.java | 32 +++ .../factory/BasicProfilesFactory.java | 16 +- .../internal/profiles/StateFilterProfile.java | 219 ++++++++++++++++++ .../resources/OH-INF/config/state-filter.xml | 18 ++ .../factory/BasicProfilesFactoryTest.java | 9 +- .../profiles/StateFilterProfileTest.java | 216 +++++++++++++++++ 7 files changed, 535 insertions(+), 6 deletions(-) create mode 100644 bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/config/StateFilterProfileConfig.java create mode 100644 bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfile.java create mode 100644 bundles/org.smarthomej.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml create mode 100644 bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java diff --git a/bundles/org.smarthomej.transform.basicprofiles/README.md b/bundles/org.smarthomej.transform.basicprofiles/README.md index 3d622f5751..7b5e4ee074 100644 --- a/bundles/org.smarthomej.transform.basicprofiles/README.md +++ b/bundles/org.smarthomej.transform.basicprofiles/README.md @@ -177,3 +177,34 @@ Switch motionSensorFirstFloor { channel="deconz:colortemperaturelight:AAA:BBB:brightness" [profile="basic-profiles:time-range-command", inRangeValue=100, outOfRangeValue=15, start="08:00", end="23:00", restoreValue="PREVIOUS"] } ``` + +## State Filter Profile + +This filter passes on state updates from a (binding) handler to the item if and only if all listed item state conditions +are met (conditions are ANDed together). +Option to instead pass different state update in case the conditions are not met. +State values may be quoted to treat as `StringType`. + +Use case: Ignore values from a binding unless some other item(s) have a specific state. + +### Configuration + +| Configuration Parameter | Type | Description | +|-------------------------|------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `conditions` | text | Comma separated list of expressions on the format `ITEM_NAME OPERATOR ITEM_STATE`, ie `MyItem EQ OFF`. Use quotes around `ITEM_STATE` to treat value as string ie `'OFF'` and not `OnOffType.OFF` | +| `mismatchState` | text | Optional state to pass instead if conditions are NOT met. Use single quotes to treat as `StringType`. Defaults to `UNDEF` | +| `separator` | text | Optional separator string to separate expressions when using multiple. Defaults to `,` | + +Possible values for token `OPERATOR` in `conditions`: + +- `EQ` - Equals +- `NEQ` - Not equals + + +### Full Example + +```Java +Number:Temperature airconTemperature{ + channel="mybinding:mything:mychannel"[profile="basic-profiles:state-filter",conditions="airconPower_item EQ ON",mismatchState="UNDEF"] +} +``` diff --git a/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/config/StateFilterProfileConfig.java b/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/config/StateFilterProfileConfig.java new file mode 100644 index 0000000000..77f1c81f0c --- /dev/null +++ b/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/config/StateFilterProfileConfig.java @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.smarthomej.transform.basicprofiles.internal.config; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.types.UnDefType; +import org.smarthomej.transform.basicprofiles.internal.profiles.StateFilterProfile; + +/** + * Configuration class for {@link StateFilterProfile}. + * + * @author Arne Seime - Initial contribution + */ +@NonNullByDefault +public class StateFilterProfileConfig { + + public String conditions = ""; + + public String mismatchState = UnDefType.UNDEF.toString(); + + public String separator = ","; +} diff --git a/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactory.java b/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactory.java index bed0cb6d86..03b92e8e2f 100644 --- a/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactory.java +++ b/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactory.java @@ -24,6 +24,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.i18n.LocalizedKey; +import org.openhab.core.items.ItemRegistry; import org.openhab.core.library.CoreItemFactory; import org.openhab.core.thing.Channel; import org.openhab.core.thing.DefaultSystemChannelTypeProvider; @@ -49,6 +50,7 @@ import org.smarthomej.transform.basicprofiles.internal.profiles.GenericToggleSwitchTriggerProfile; import org.smarthomej.transform.basicprofiles.internal.profiles.InvertStateProfile; import org.smarthomej.transform.basicprofiles.internal.profiles.RoundStateProfile; +import org.smarthomej.transform.basicprofiles.internal.profiles.StateFilterProfile; import org.smarthomej.transform.basicprofiles.internal.profiles.ThresholdStateProfile; import org.smarthomej.transform.basicprofiles.internal.profiles.TimeRangeCommandProfile; @@ -69,6 +71,7 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider public static final ProfileTypeUID ROUND_UID = new ProfileTypeUID(SCOPE, "round"); public static final ProfileTypeUID THRESHOLD_UID = new ProfileTypeUID(SCOPE, "threshold"); public static final ProfileTypeUID TIME_RANGE_COMMAND_UID = new ProfileTypeUID(SCOPE, "time-range-command"); + public static final ProfileTypeUID STATE_FILTER_UID = new ProfileTypeUID(SCOPE, "state-filter"); private static final ProfileType PROFILE_TYPE_GENERIC_COMMAND = ProfileTypeBuilder .newTrigger(GENERIC_COMMAND_UID, "Generic Command") // @@ -102,24 +105,29 @@ public class BasicProfilesFactory implements ProfileFactory, ProfileTypeProvider .withSupportedItemTypes(CoreItemFactory.SWITCH) // .withSupportedChannelTypeUIDs(DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_MOTION) // .build(); + private static final ProfileType PROFILE_STATE_FILTER = ProfileTypeBuilder + .newState(STATE_FILTER_UID, "Filter handler state updates based on any item state").build(); private static final Set SUPPORTED_PROFILE_TYPE_UIDS = Set.of(GENERIC_COMMAND_UID, GENERIC_TOGGLE_SWITCH_UID, DEBOUNCE_COUNTING_UID, DEBOUNCE_TIME_UID, INVERT_UID, ROUND_UID, THRESHOLD_UID, - TIME_RANGE_COMMAND_UID); + TIME_RANGE_COMMAND_UID, STATE_FILTER_UID); private static final Set SUPPORTED_PROFILE_TYPES = Set.of(PROFILE_TYPE_GENERIC_COMMAND, PROFILE_TYPE_GENERIC_TOGGLE_SWITCH, PROFILE_TYPE_DEBOUNCE_COUNTING, PROFILE_TYPE_DEBOUNCE_TIME, - PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND); + PROFILE_TYPE_INVERT, PROFILE_TYPE_ROUND, PROFILE_TYPE_THRESHOLD, PROFILE_TYPE_TIME_RANGE_COMMAND, + PROFILE_STATE_FILTER); private final Map localizedProfileTypeCache = new ConcurrentHashMap<>(); private final ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService; private final Bundle bundle; + private final ItemRegistry itemRegistry; @Activate public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService profileTypeI18nLocalizationService, - final @Reference BundleResolver bundleResolver) { + final @Reference BundleResolver bundleResolver, @Reference ItemRegistry itemRegistry) { this.profileTypeI18nLocalizationService = profileTypeI18nLocalizationService; this.bundle = bundleResolver.resolveBundle(BasicProfilesFactory.class); + this.itemRegistry = itemRegistry; } @Override @@ -141,6 +149,8 @@ public BasicProfilesFactory(final @Reference ProfileTypeI18nLocalizationService return new ThresholdStateProfile(callback, context); } else if (TIME_RANGE_COMMAND_UID.equals(profileTypeUID)) { return new TimeRangeCommandProfile(callback, context); + } else if (STATE_FILTER_UID.equals(profileTypeUID)) { + return new StateFilterProfile(callback, context, itemRegistry); } return null; } diff --git a/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfile.java b/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfile.java new file mode 100644 index 0000000000..7cc1f71deb --- /dev/null +++ b/bundles/org.smarthomej.transform.basicprofiles/src/main/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfile.java @@ -0,0 +1,219 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.smarthomej.transform.basicprofiles.internal.profiles; + +import static org.smarthomej.transform.basicprofiles.internal.factory.BasicProfilesFactory.STATE_FILTER_UID; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +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.StringType; +import org.openhab.core.thing.profiles.ProfileCallback; +import org.openhab.core.thing.profiles.ProfileContext; +import org.openhab.core.thing.profiles.ProfileTypeUID; +import org.openhab.core.thing.profiles.StateProfile; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.TypeParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.smarthomej.transform.basicprofiles.internal.config.StateFilterProfileConfig; + +/** + * Accepts updates to state as long as conditions are met. Support for sending fixed state if conditions are *not* + * met. + * + * @author Arne Seime - Initial contribution + */ +@NonNullByDefault +public class StateFilterProfile implements StateProfile { + + private final Logger logger = LoggerFactory.getLogger(StateFilterProfile.class); + + private final ItemRegistry itemRegistry; + private final ProfileCallback callback; + private List> acceptedDataTypes; + + private List conditions = List.of(); + + private @Nullable State configMismatchState = null; + + 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); + } + } + + private List parseConditions(@Nullable String config, String separator) { + if (config == null) { + return List.of(); + } + + List 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 STATE_VALUE: '{}'", config, + e.getMessage()); + return List.of(); + } + } + + @Override + public ProfileTypeUID getProfileTypeUID() { + return STATE_FILTER_UID; + } + + @Override + public void onStateUpdateFromItem(State state) { + // do nothing + } + + @Override + public void onCommandFromItem(Command command) { + callback.handleCommand(command); + } + + @Override + public void onCommandFromHandler(Command command) { + callback.sendCommand(command); + } + + @Override + public void onStateUpdateFromHandler(State state) { + State resultState = checkCondition(state); + if (resultState != null) { + logger.debug("Received state update from handler: {}, forwarded as {}", state, resultState); + callback.sendUpdate(resultState); + } else { + logger.debug("Received state update from handler: {}, not forwarded to item", state); + } + } + + @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 { + logger.warn( + "No configuration defined for StateFilterProfile (check for log messages when instantiating profile) - skipping state update"); + } + + return null; + } + + @Nullable + State parseState(@Nullable String stateString) { + // Quoted strings are parsed as StringType + if (stateString == null) { + return null; + } else if (stateString.startsWith("'") && stateString.endsWith("'")) { + return new StringType(stateString.substring(1, stateString.length() - 1)); + } else { + return TypeParser.parseState(acceptedDataTypes, stateString); + } + } + + class StateCondition { + String itemName; + + ComparisonType comparisonType; + String value; + + boolean quoted = false; + + 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); + } + } + + public boolean matches(String state) { + switch (comparisonType) { + case EQ: + return state.equals(value); + case NEQ: { + return !state.equals(value); + } + default: + logger.warn("Unknown condition type {}. Expected 'eq' or 'neq' - skipping state update", + comparisonType); + return false; + + } + } + + enum ComparisonType { + EQ, + NEQ + } + + @Override + public String toString() { + return "Condition{itemName='" + itemName + "', comparisonType=" + comparisonType + ", value='" + value + + "'}'"; + } + } +} diff --git a/bundles/org.smarthomej.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml b/bundles/org.smarthomej.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml new file mode 100644 index 0000000000..c468dcd9fd --- /dev/null +++ b/bundles/org.smarthomej.transform.basicprofiles/src/main/resources/OH-INF/config/state-filter.xml @@ -0,0 +1,18 @@ + + + + + + + Comma separated list of expressions on the format ITEM_NAME OPERATOR ITEM_STATE, ie "MyItem EQ OFF". Use + quotes around ITEM_STATE to treat value as string ie "'OFF'". + + + + State to pass to item instead if conditions are NOT met. Use quotes to treat as `StringType` + + + diff --git a/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java b/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java index 4e2703d5e7..ea0596bb74 100644 --- a/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java +++ b/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/factory/BasicProfilesFactoryTest.java @@ -14,7 +14,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.collection.IsCollectionWithSize.hasSize; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.when; import java.util.Collection; @@ -29,6 +30,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.openhab.core.config.core.Configuration; +import org.openhab.core.items.ItemRegistry; import org.openhab.core.library.types.OnOffType; import org.openhab.core.thing.profiles.ProfileCallback; import org.openhab.core.thing.profiles.ProfileContext; @@ -51,7 +53,7 @@ @NonNullByDefault public class BasicProfilesFactoryTest { - private static final int NUMBER_OF_PROFILES = 8; + private static final int NUMBER_OF_PROFILES = 9; private static final Map PROPERTIES = Map.of(ThresholdStateProfile.PARAM_THRESHOLD, 15, RoundStateProfile.PARAM_SCALE, 2, GenericCommandTriggerProfile.PARAM_EVENTS, "1002,1003", @@ -63,12 +65,13 @@ public class BasicProfilesFactoryTest { private @Mock @NonNullByDefault({}) BundleResolver mockBundleResolver; private @Mock @NonNullByDefault({}) ProfileCallback mockCallback; private @Mock @NonNullByDefault({}) ProfileContext mockContext; + private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry; private @NonNullByDefault({}) BasicProfilesFactory profileFactory; @BeforeEach public void setup() { - profileFactory = new BasicProfilesFactory(mockLocalizationService, mockBundleResolver); + profileFactory = new BasicProfilesFactory(mockLocalizationService, mockBundleResolver, mockItemRegistry); when(mockContext.getConfiguration()).thenReturn(CONFIG); } diff --git a/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java b/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java new file mode 100644 index 0000000000..b98bd23b53 --- /dev/null +++ b/bundles/org.smarthomej.transform.basicprofiles/src/test/java/org/smarthomej/transform/basicprofiles/internal/profiles/StateFilterProfileTest.java @@ -0,0 +1,216 @@ +/** + * Copyright (c) 2021-2023 Contributors to the SmartHome/J project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.smarthomej.transform.basicprofiles.internal.profiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +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.List; +import java.util.Map; + +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.mockito.Mock; +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.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.thing.profiles.ProfileCallback; +import org.openhab.core.thing.profiles.ProfileContext; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * Basic unit tests for {@link StateFilterProfile}. + * + * @author Arne Seime - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.WARN) +@NonNullByDefault +public class StateFilterProfileTest { + + private @Mock @NonNullByDefault({}) ProfileCallback mockCallback; + private @Mock @NonNullByDefault({}) ProfileContext mockContext; + private @Mock @NonNullByDefault({}) ItemRegistry mockItemRegistry; + + @BeforeEach + public void setup() { + reset(mockContext); + reset(mockCallback); + } + + @Test + public void testNoConditions() { + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", ""))); + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + State expectation = OnOffType.ON; + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(0)).sendUpdate(eq(expectation)); + } + + @Test + public void testMalformedConditions() { + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName invalid"))); + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + State expectation = OnOffType.ON; + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(0)).sendUpdate(eq(expectation)); + } + + @Test + public void testInvalidComparatorConditions() throws ItemNotFoundException { + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName lt Value"))); + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class); + + State expectation = OnOffType.ON; + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(0)).sendUpdate(eq(expectation)); + } + + @Test + public void testInvalidItemConditions() throws ItemNotFoundException { + when(mockContext.getConfiguration()).thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value"))); + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class); + State expectation = OnOffType.ON; + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(0)).sendUpdate(eq(expectation)); + } + + @Test + public void testInvalidMultipleConditions() throws ItemNotFoundException { + when(mockContext.getConfiguration()) + .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value,itemname invalid"))); + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + when(mockItemRegistry.getItem(any())).thenThrow(ItemNotFoundException.class); + + State expectation = OnOffType.ON; + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(0)).sendUpdate(eq(expectation)); + } + + @Test + public void testSingleConditionMatch() throws ItemNotFoundException { + 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); + + State expectation = new StringType("NewValue"); + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(1)).sendUpdate(eq(expectation)); + } + + @Test + public void testSingleConditionMatchQuoted() throws ItemNotFoundException { + 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); + + State expectation = new StringType("NewValue"); + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(1)).sendUpdate(eq(expectation)); + } + + private Item stringItemWithState(String itemName, String value) { + StringItem item = new StringItem(itemName); + item.setState(new StringType(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"))); + when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value")); + when(mockItemRegistry.getItem("ItemName2")).thenReturn(stringItemWithState("ItemName2", "Value2")); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + State expectation = new StringType("NewValue"); + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(1)).sendUpdate(eq(expectation)); + } + + @Test + public void testMultipleCondition_SingleMatch() throws ItemNotFoundException { + when(mockContext.getConfiguration()) + .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value, ItemName2 eq Value2"))); + when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Value")); + when(mockItemRegistry.getItem("ItemName2")).thenThrow(ItemNotFoundException.class); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + State expectation = new StringType("NewValue"); + profile.onStateUpdateFromHandler(expectation); + verify(mockCallback, times(0)).sendUpdate(eq(expectation)); + } + + @Test + public void testFailingConditionWithMismatchState() throws ItemNotFoundException { + when(mockContext.getConfiguration()) + .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value", "mismatchState", "UNDEF"))); + when(mockContext.getAcceptedDataTypes()).thenReturn(List.of(UnDefType.class, StringType.class)); + when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Mismatch")); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + profile.onStateUpdateFromHandler(new StringType("ToBeDiscarded")); + verify(mockCallback, times(1)).sendUpdate(eq(UnDefType.UNDEF)); + } + + @Test + public void testFailingConditionWithMismatchStateQuoted() throws ItemNotFoundException { + when(mockContext.getConfiguration()) + .thenReturn(new Configuration(Map.of("conditions", "ItemName eq Value", "mismatchState", "'UNDEF'"))); + when(mockContext.getAcceptedDataTypes()).thenReturn(List.of(UnDefType.class, StringType.class)); + when(mockItemRegistry.getItem("ItemName")).thenReturn(stringItemWithState("ItemName", "Mismatch")); + + StateFilterProfile profile = new StateFilterProfile(mockCallback, mockContext, mockItemRegistry); + + profile.onStateUpdateFromHandler(new StringType("ToBeDiscarded")); + verify(mockCallback, times(1)).sendUpdate(eq(new StringType("UNDEF"))); + } + + @Test + void testParseStateNonQuotes() { + when(mockContext.getAcceptedDataTypes()) + .thenReturn(List.of(UnDefType.class, OnOffType.class, StringType.class)); + 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'")); + } +}