diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/IntervalConditionHandler.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/IntervalConditionHandler.java new file mode 100644 index 00000000000..7ec764f015c --- /dev/null +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/IntervalConditionHandler.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB 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.openhab.core.automation.internal.module.handler; + +import java.math.BigDecimal; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.automation.Condition; +import org.openhab.core.automation.handler.BaseConditionModuleHandler; +import org.openhab.core.config.core.Configuration; + +/** + * ConditionHandler implementation for trigger interval limiting. + * + * @author Jimmy Tanagra - Initial contribution + */ +@NonNullByDefault +public class IntervalConditionHandler extends BaseConditionModuleHandler { + + public static final String MODULE_TYPE_ID = "timer.IntervalCondition"; + + /** + * Constants for Config-Parameters corresponding to Definition in + * IntervalConditionHandler.json + */ + public static final String CFG_MIN_INTERVAL = "minInterval"; + + /** + * The minimum interval stored in nano seconds. + */ + private long minInterval; + + private @Nullable Long lastAcceptedTime = null; + + public IntervalConditionHandler(Condition condition) { + super(condition); + Configuration configuration = module.getConfiguration(); + this.minInterval = ((BigDecimal) configuration.get(CFG_MIN_INTERVAL)).longValue() * 1000000L; + } + + @Override + public boolean isSatisfied(Map inputs) { + long currentTime = System.nanoTime(); + if (lastAcceptedTime == null || currentTime - lastAcceptedTime >= minInterval) { + lastAcceptedTime = currentTime; + return true; + } + return false; + } +} diff --git a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/TimerModuleHandlerFactory.java b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/TimerModuleHandlerFactory.java index 826b755cc79..af08545ff56 100644 --- a/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/TimerModuleHandlerFactory.java +++ b/bundles/org.openhab.core.automation/src/main/java/org/openhab/core/automation/internal/module/handler/TimerModuleHandlerFactory.java @@ -49,7 +49,8 @@ public class TimerModuleHandlerFactory extends BaseModuleHandlerFactory { public static final String THREADPOOLNAME = "ruletimer"; private static final Collection TYPES = Arrays.asList(GenericCronTriggerHandler.MODULE_TYPE_ID, TimeOfDayTriggerHandler.MODULE_TYPE_ID, TimeOfDayConditionHandler.MODULE_TYPE_ID, - DayOfWeekConditionHandler.MODULE_TYPE_ID, DateTimeTriggerHandler.MODULE_TYPE_ID); + DayOfWeekConditionHandler.MODULE_TYPE_ID, DateTimeTriggerHandler.MODULE_TYPE_ID, + IntervalConditionHandler.MODULE_TYPE_ID); private final CronScheduler scheduler; private final ItemRegistry itemRegistry; @@ -78,21 +79,26 @@ public Collection getTypes() { protected @Nullable ModuleHandler internalCreate(Module module, String ruleUID) { logger.trace("create {} -> {}", module.getId(), module.getTypeUID()); String moduleTypeUID = module.getTypeUID(); - if (GenericCronTriggerHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Trigger trigger) { - return new GenericCronTriggerHandler(trigger, scheduler); - } else if (TimeOfDayTriggerHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Trigger trigger) { - return new TimeOfDayTriggerHandler(trigger, scheduler); - } else if (DateTimeTriggerHandler.MODULE_TYPE_ID.equals(moduleTypeUID) && module instanceof Trigger trigger) { - return new DateTimeTriggerHandler(trigger, scheduler, itemRegistry, bundleContext); - } else if (TimeOfDayConditionHandler.MODULE_TYPE_ID.equals(moduleTypeUID) - && module instanceof Condition condition) { - return new TimeOfDayConditionHandler(condition); - } else if (DayOfWeekConditionHandler.MODULE_TYPE_ID.equals(moduleTypeUID) - && module instanceof Condition condition) { - return new DayOfWeekConditionHandler(condition); - } else { - logger.error("The module handler type '{}' is not supported.", moduleTypeUID); + if (module instanceof Trigger trigger) { + switch (moduleTypeUID) { + case GenericCronTriggerHandler.MODULE_TYPE_ID: + return new GenericCronTriggerHandler(trigger, scheduler); + case TimeOfDayTriggerHandler.MODULE_TYPE_ID: + return new TimeOfDayTriggerHandler(trigger, scheduler); + case DateTimeTriggerHandler.MODULE_TYPE_ID: + return new DateTimeTriggerHandler(trigger, scheduler, itemRegistry, bundleContext); + } + } else if (module instanceof Condition condition) { + switch (moduleTypeUID) { + case TimeOfDayConditionHandler.MODULE_TYPE_ID: + return new TimeOfDayConditionHandler(condition); + case DayOfWeekConditionHandler.MODULE_TYPE_ID: + return new DayOfWeekConditionHandler(condition); + case IntervalConditionHandler.MODULE_TYPE_ID: + return new IntervalConditionHandler(condition); + } } + logger.error("The module handler type '{}' is not supported.", moduleTypeUID); return null; } } diff --git a/bundles/org.openhab.core.automation/src/main/resources/OH-INF/automation/moduletypes/IntervalCondition.json b/bundles/org.openhab.core.automation/src/main/resources/OH-INF/automation/moduletypes/IntervalCondition.json new file mode 100644 index 00000000000..06d21f52b7b --- /dev/null +++ b/bundles/org.openhab.core.automation/src/main/resources/OH-INF/automation/moduletypes/IntervalCondition.json @@ -0,0 +1,18 @@ +{ + "conditions": [ + { + "uid": "timer.IntervalCondition", + "label": "a minimum interval between checks is reached", + "description": "Evaluates the interval between checks.", + "configDescriptions": [ + { + "name": "minInterval", + "type": "INTEGER", + "label": "Minimum Interval", + "description": "Returns true if the last satisfied check was at least this many milliseconds ago.", + "required": true + } + ] + } + ] +} diff --git a/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/BasicConditionHandlerTest.java b/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/BasicConditionHandlerTest.java index 5275a253664..66e14a00659 100644 --- a/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/BasicConditionHandlerTest.java +++ b/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/BasicConditionHandlerTest.java @@ -74,9 +74,9 @@ public abstract class BasicConditionHandlerTest extends JavaOSGiTest { private final Logger logger = LoggerFactory.getLogger(BasicConditionHandlerTest.class); private VolatileStorageService volatileStorageService = new VolatileStorageService(); - private @NonNullByDefault({}) RuleRegistry ruleRegistry; - private @NonNullByDefault({}) RuleManager ruleEngine; - private @Nullable Event itemEvent; + protected @NonNullByDefault({}) RuleRegistry ruleRegistry; + protected @NonNullByDefault({}) RuleManager ruleEngine; + protected @Nullable Event itemEvent; private @NonNullByDefault({}) StartLevelService startLevelService; /** @@ -128,7 +128,7 @@ public void removeProviderChangeListener(ProviderChangeListener listener) } @Test - public void assertThatConditionWorksInRule() throws ItemNotFoundException { + public void assertThatConditionWorksInRule() throws ItemNotFoundException, InterruptedException { String testItemName1 = "TriggeredItem"; String testItemName2 = "SwitchedItem"; @@ -208,9 +208,8 @@ public void receive(Event event) { // prepare the execution itemEvent = null; eventPublisher.post(ItemEventFactory.createStateUpdatedEvent(testItemName1, OnOffType.ON)); - waitForAssert(() -> { - assertThat(itemEvent, is(nullValue())); - }); + Thread.sleep(200); // without this, the assertion will be immediately fulfilled regardless of event processing + assertThat(itemEvent, is(nullValue())); } /** diff --git a/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/IntervalConditionHandlerTest.java b/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/IntervalConditionHandlerTest.java new file mode 100644 index 00000000000..a27bc868092 --- /dev/null +++ b/itests/org.openhab.core.automation.module.timer.tests/src/main/java/org/openhab/core/automation/module/timer/internal/IntervalConditionHandlerTest.java @@ -0,0 +1,225 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB 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.openhab.core.automation.module.timer.internal; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Random; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.automation.Action; +import org.openhab.core.automation.Condition; +import org.openhab.core.automation.Rule; +import org.openhab.core.automation.RuleManager; +import org.openhab.core.automation.RuleStatus; +import org.openhab.core.automation.RuleStatusInfo; +import org.openhab.core.automation.Trigger; +import org.openhab.core.automation.internal.RuleEngineImpl; +import org.openhab.core.automation.internal.module.handler.IntervalConditionHandler; +import org.openhab.core.automation.internal.module.handler.ItemCommandActionHandler; +import org.openhab.core.automation.internal.module.handler.ItemStateTriggerHandler; +import org.openhab.core.automation.type.ModuleTypeRegistry; +import org.openhab.core.automation.util.ModuleBuilder; +import org.openhab.core.automation.util.RuleBuilder; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.events.EventSubscriber; +import org.openhab.core.items.ItemNotFoundException; +import org.openhab.core.items.events.ItemCommandEvent; +import org.openhab.core.items.events.ItemEventFactory; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.service.ReadyMarker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This tests the Interval Condition. + * + * @author Jimmy Tanagra - Initial contribution + */ +@NonNullByDefault +public class IntervalConditionHandlerTest extends BasicConditionHandlerTest { + private final Logger logger = LoggerFactory.getLogger(IntervalConditionHandlerTest.class); + + /** + * This checks if the condition on its own works properly. + */ + @Test + public void assertThatConditionWorks() throws InterruptedException { + // The minimum interval is 10ms + IntervalConditionHandler handler = getIntervalConditionHandler(BigDecimal.valueOf(100)); + // First execution -> should return true + assertThat(handler.isSatisfied(Map.of()), is(true)); + // Subsequent immediate execution -> should return false + assertThat(handler.isSatisfied(Map.of()), is(false)); + Thread.sleep(200); + // Execute after 200ms -> should return true + assertThat(handler.isSatisfied(Map.of()), is(true)); + } + + private IntervalConditionHandler getIntervalConditionHandler(BigDecimal minInterval) { + return new IntervalConditionHandler(getIntervalCondition(minInterval)); + } + + private Condition getIntervalCondition(BigDecimal minInterval) { + Configuration config = getIntervalConfiguration(minInterval); + return ModuleBuilder.createCondition().withId("testIntervalCondition") + .withTypeUID(IntervalConditionHandler.MODULE_TYPE_ID).withConfiguration(config).build(); + } + + private Configuration getIntervalConfiguration(BigDecimal minInterval) { + return new Configuration(Map.of(IntervalConditionHandler.CFG_MIN_INTERVAL, minInterval)); + } + + @Override + public Condition getPassingCondition() { + return getIntervalCondition(BigDecimal.valueOf(100)); + } + + @Override + public Configuration getFailingConfiguration() { + return getIntervalConfiguration(BigDecimal.valueOf(10000)); + } + + // This is copied from BasicConditionHandlerTest with some modifications + @Override + @Test + public void assertThatConditionWorksInRule() throws ItemNotFoundException, InterruptedException { + String testItemName1 = "TriggeredItem"; + String testItemName2 = "SwitchedItem"; + + /* + * Create Rule + */ + logger.info("Create rule"); + Configuration triggerConfig = new Configuration(Map.of("itemName", testItemName1)); + List triggers = List.of(ModuleBuilder.createTrigger().withId("MyTrigger") + .withTypeUID(ItemStateTriggerHandler.UPDATE_MODULE_TYPE_ID).withConfiguration(triggerConfig).build()); + + List conditions = List.of(getPassingCondition()); + + Map cfgEntries = new HashMap<>(); + cfgEntries.put("itemName", testItemName2); + cfgEntries.put("command", "ON"); + Configuration actionConfig = new Configuration(cfgEntries); + List actions = List.of(ModuleBuilder.createAction().withId("MyItemPostCommandAction") + .withTypeUID(ItemCommandActionHandler.ITEM_COMMAND_ACTION).withConfiguration(actionConfig).build()); + + // prepare the execution + EventPublisher eventPublisher = getService(EventPublisher.class); + + // start rule engine + RuleEngineImpl ruleEngine = Objects.requireNonNull((RuleEngineImpl) getService(RuleManager.class)); + ruleEngine.onReadyMarkerAdded(new ReadyMarker("", "")); + waitForAssert(() -> assertTrue(ruleEngine.isStarted())); + + EventSubscriber itemEventHandler = new EventSubscriber() { + + @Override + public Set getSubscribedEventTypes() { + return Set.of(ItemCommandEvent.TYPE); + } + + @Override + public void receive(Event event) { + logger.info("Event: {}", event.getTopic()); + if (event.getTopic().contains(testItemName2)) { + IntervalConditionHandlerTest.this.itemEvent = event; + } + } + }; + registerService(itemEventHandler); + + Rule rule = RuleBuilder.create("MyRule" + new Random().nextInt()).withTriggers(triggers) + .withConditions(conditions).withActions(actions).withName("MyConditionTestRule").build(); + logger.info("Rule created: {}", rule.getUID()); + + logger.info("Add rule"); + ruleRegistry.add(rule); + logger.info("Rule added"); + + logger.info("Enable rule and wait for idle status"); + ruleEngine.setEnabled(rule.getUID(), true); + waitForAssert(() -> { + final RuleStatusInfo ruleStatus = ruleEngine.getStatusInfo(rule.getUID()); + assertThat(ruleStatus.getStatus(), is(RuleStatus.IDLE)); + }); + logger.info("Rule is enabled and idle"); + + logger.info("Send and wait for item state is ON"); + eventPublisher.post(ItemEventFactory.createStateUpdatedEvent(testItemName1, OnOffType.ON)); + + // the first event is always processed + waitForAssert(() -> { + assertThat(itemEvent, is(notNullValue())); + assertThat(((ItemCommandEvent) itemEvent).getItemCommand(), is(OnOffType.ON)); + }); + + long minInterval = ((BigDecimal) conditions.getFirst().getConfiguration() + .get(IntervalConditionHandler.CFG_MIN_INTERVAL)).longValue(); + Thread.sleep(minInterval + 50); + + // Send a second event to check if the condition is still satisfied + itemEvent = null; // reset it + eventPublisher.post(ItemEventFactory.createStateUpdatedEvent(testItemName1, OnOffType.ON)); + + waitForAssert(() -> { + assertThat(itemEvent, is(notNullValue())); + assertThat(((ItemCommandEvent) itemEvent).getItemCommand(), is(OnOffType.ON)); + }); + logger.info("item state is ON"); + + // now make the condition fail + Rule rule2 = RuleBuilder.create(rule).withConditions(ModuleBuilder + .createCondition(rule.getConditions().getFirst()).withConfiguration(getFailingConfiguration()).build()) + .build(); + ruleRegistry.update(rule2); + + // prepare the execution + itemEvent = null; + eventPublisher.post(ItemEventFactory.createStateUpdatedEvent(testItemName1, OnOffType.ON)); + + // the first event is always allowed + waitForAssert(() -> { + assertThat(itemEvent, is(notNullValue())); + }); + + Thread.sleep(200); // some time is passing but less than the failing condition's minInterval + + // the second event is not allowed + itemEvent = null; + eventPublisher.post(ItemEventFactory.createStateUpdatedEvent(testItemName1, OnOffType.ON)); + Thread.sleep(200); // without this, the assertion will be immediately fulfilled regardless of event processing + assertThat(itemEvent, is(nullValue())); + } + + @SuppressWarnings("null") + @Test + public void checkIfModuleTypeIsRegistered() { + ModuleTypeRegistry mtr = getService(ModuleTypeRegistry.class); + waitForAssert(() -> { + assertThat(mtr.get(IntervalConditionHandler.MODULE_TYPE_ID), is(notNullValue())); + }, 3000, 100); + } +}