Skip to content

Commit

Permalink
Add IntervalConditionHandler
Browse files Browse the repository at this point in the history
Signed-off-by: Jimmy Tanagra <[email protected]>
  • Loading branch information
jimtng committed Feb 1, 2025
1 parent 7b85d6d commit fed3bc1
Show file tree
Hide file tree
Showing 5 changed files with 332 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -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<String, Object> inputs) {
long currentTime = System.nanoTime();
if (lastAcceptedTime == null || currentTime - lastAcceptedTime >= minInterval) {
lastAcceptedTime = currentTime;
return true;
}
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ public class TimerModuleHandlerFactory extends BaseModuleHandlerFactory {
public static final String THREADPOOLNAME = "ruletimer";
private static final Collection<String> 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;
Expand Down Expand Up @@ -78,21 +79,26 @@ public Collection<String> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -128,7 +128,7 @@ public void removeProviderChangeListener(ProviderChangeListener<Item> listener)
}

@Test
public void assertThatConditionWorksInRule() throws ItemNotFoundException {
public void assertThatConditionWorksInRule() throws ItemNotFoundException, InterruptedException {
String testItemName1 = "TriggeredItem";
String testItemName2 = "SwitchedItem";

Expand Down Expand Up @@ -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()));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Trigger> triggers = List.of(ModuleBuilder.createTrigger().withId("MyTrigger")
.withTypeUID(ItemStateTriggerHandler.UPDATE_MODULE_TYPE_ID).withConfiguration(triggerConfig).build());

List<Condition> conditions = List.of(getPassingCondition());

Map<String, Object> cfgEntries = new HashMap<>();
cfgEntries.put("itemName", testItemName2);
cfgEntries.put("command", "ON");
Configuration actionConfig = new Configuration(cfgEntries);
List<Action> 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<String> 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);
}
}

0 comments on commit fed3bc1

Please sign in to comment.