From 5289d9b152426b404b50bd0ae371385de6cde245 Mon Sep 17 00:00:00 2001 From: Rich Turner Date: Thu, 3 Jan 2019 20:42:37 +0000 Subject: [PATCH] JSON rules initial version complete --- .../manager/asset/AssetStorageService.java | 22 +- .../manager/rules/AssetQueryPredicate.java | 9 +- .../manager/rules/JsonRulesBuilder.java | 594 +++++++++++------- .../openremote/manager/rules/RulesEngine.java | 8 +- .../manager/rules/RulesetDeployment.java | 175 ++++-- .../model/query/filter/ParentPredicate.java | 6 + .../org/openremote/model/rules/json/Rule.java | 2 +- .../rules/json/RuleActionNotification.java | 4 +- .../model/rules/json/RuleActionWait.java | 2 +- .../rules/json/RuleActionWithTarget.java | 19 +- .../rules/json/RuleActionWriteAttribute.java | 4 +- .../model/rules/json/RuleTriggerReset.java | 6 +- ...ofenceTest.groovy => JsonRulesTest.groovy} | 137 ++-- ...ionPredicates.json => BasicJsonRules.json} | 39 +- 14 files changed, 673 insertions(+), 354 deletions(-) rename test/src/test/groovy/org/openremote/test/rules/residence/{ResidenceLightsOnGeofenceTest.groovy => JsonRulesTest.groovy} (56%) rename test/src/test/resources/org/openremote/test/rules/{BasicLocationPredicates.json => BasicJsonRules.json} (51%) diff --git a/manager/src/main/java/org/openremote/manager/asset/AssetStorageService.java b/manager/src/main/java/org/openremote/manager/asset/AssetStorageService.java index 90ffee0fd5..6673091aed 100644 --- a/manager/src/main/java/org/openremote/manager/asset/AssetStorageService.java +++ b/manager/src/main/java/org/openremote/manager/asset/AssetStorageService.java @@ -806,7 +806,7 @@ protected String buildFromString(BaseAssetQuery query, int level) { } if (level == 1) { - if (query.parent != null && !query.parent.noParent && (query.parent.id != null || query.parent.type != null)) { + if (query.parent != null && !query.parent.noParent && (query.parent.id != null || query.parent.type != null || query.parent.name != null)) { sb.append("cross join ASSET P "); } else { sb.append("left outer join ASSET P on A.PARENT_ID = P.ID "); @@ -890,19 +890,27 @@ protected String buildWhereClause(BaseAssetQuery query, int level, List st.setString(pos, query.parent.id)); - } else if (query.parent.type != null) { - sb.append(" and p.ID = a.PARENT_ID"); - sb.append(" and P.ASSET_TYPE = ?"); - final int pos = binders.size() + 1; - binders.add(st -> st.setString(pos, query.parent.type)); } else if (level == 1 && query.parent.noParent) { sb.append(" and A.PARENT_ID is null"); + } else if (query.parent.type != null || query.parent.name != null) { + + sb.append(" and p.ID = a.PARENT_ID"); + + if (query.parent.type != null) { + sb.append(" and P.ASSET_TYPE = ?"); + final int pos = binders.size() + 1; + binders.add(st -> st.setString(pos, query.parent.type)); + } + if (query.parent.name != null) { + sb.append(" and P.NAME = ?"); + final int pos = binders.size() + 1; + binders.add(st -> st.setString(pos, query.parent.name)); + } } } diff --git a/manager/src/main/java/org/openremote/manager/rules/AssetQueryPredicate.java b/manager/src/main/java/org/openremote/manager/rules/AssetQueryPredicate.java index 3cc135a8f6..2ce35f1972 100644 --- a/manager/src/main/java/org/openremote/manager/rules/AssetQueryPredicate.java +++ b/manager/src/main/java/org/openremote/manager/rules/AssetQueryPredicate.java @@ -235,6 +235,7 @@ public static Predicate asPredicate(ParentPredicate predicate) { return assetState -> (predicate.id == null || predicate.id.equals(assetState.getParentId())) && (predicate.type == null || predicate.type.equals(assetState.getParentTypeString())) + && (predicate.name == null || predicate.name.equals(assetState.getParentName())) && (!predicate.noParent || assetState.getParentId() == null); } @@ -260,7 +261,10 @@ public static Predicate asPredicate(GeofencePredicate predicate) { GeodeticCalculator calculator = new GeodeticCalculator(); calculator.setStartingGeographicPoint(radialLocationPredicate.lng, radialLocationPredicate.lat); calculator.setDestinationGeographicPoint(coordinate.y, coordinate.x); - return calculator.getOrthodromicDistance() < radialLocationPredicate.radius; + if (predicate.negated) { + return calculator.getOrthodromicDistance() > radialLocationPredicate.radius; + } + return calculator.getOrthodromicDistance() <= radialLocationPredicate.radius; } else if (predicate instanceof RectangularGeofencePredicate) { // Again this is a euclidean plane so doesn't work perfectly for WGS lat/lng - the bigger the rectangle to less accurate it is) RectangularGeofencePredicate rectangularLocationPredicate = (RectangularGeofencePredicate) predicate; @@ -268,6 +272,9 @@ public static Predicate asPredicate(GeofencePredicate predicate) { rectangularLocationPredicate.lngMin, rectangularLocationPredicate.latMax, rectangularLocationPredicate.lngMax); + if (predicate.negated) { + return !envelope.contains(coordinate); + } return envelope.contains(coordinate); } else { throw new UnsupportedOperationException("Location predicate '" + predicate.getClass().getSimpleName() + "' not supported in rules matching"); diff --git a/manager/src/main/java/org/openremote/manager/rules/JsonRulesBuilder.java b/manager/src/main/java/org/openremote/manager/rules/JsonRulesBuilder.java index 0d1ef27f70..35f20d7429 100644 --- a/manager/src/main/java/org/openremote/manager/rules/JsonRulesBuilder.java +++ b/manager/src/main/java/org/openremote/manager/rules/JsonRulesBuilder.java @@ -21,234 +21,67 @@ import org.openremote.container.timer.TimerService; import org.openremote.manager.asset.AssetStorageService; +import org.openremote.manager.concurrent.ManagerExecutorService; +import org.openremote.manager.rules.facade.NotificationsFacade; import org.openremote.model.attribute.Meta; +import org.openremote.model.notification.Notification; +import org.openremote.model.query.AssetQuery; +import org.openremote.model.query.UserQuery; import org.openremote.model.query.filter.AttributeMetaPredicate; import org.openremote.model.rules.AssetState; -import org.openremote.model.rules.json.Rule; -import org.openremote.model.rules.json.RuleCondition; -import org.openremote.model.rules.json.RuleOperator; -import org.openremote.model.rules.json.RuleTriggerReset; +import org.openremote.model.rules.Assets; +import org.openremote.model.rules.Users; +import org.openremote.model.rules.json.*; import org.openremote.model.rules.json.predicate.AssetPredicate; import org.openremote.model.rules.json.predicate.AttributePredicate; import org.openremote.model.util.TextUtil; import org.openremote.model.value.Value; import java.util.*; -import java.util.function.BiPredicate; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.function.Supplier; +import java.util.function.*; import java.util.stream.Collectors; public class JsonRulesBuilder extends RulesBuilder { - public static class RuleFiredInfo { + static class RuleFiredInfo { AssetState firedAssetState; - } - - final protected AssetStorageService assetStorageService; - final protected TimerService timerService; - - public JsonRulesBuilder(TimerService timerService, AssetStorageService assetStorageService) { - this.timerService = timerService; - this.assetStorageService = assetStorageService; - } - - public JsonRulesBuilder add(Rule rule) { - - final Predicate assetStatePredicate = rule.when != null && rule.when.asset != null - ? asPredicate(timerService, assetStorageService, rule.when.asset) : null; - - Condition condition = buildLhsCondition(rule, assetStatePredicate); - Action action = buildAction(rule); - - if (condition == null || action == null) { - throw new IllegalArgumentException("Error building JSON rule '" + rule.name + "'"); + RuleFiredInfo(AssetState firedAssetState) { + this.firedAssetState = firedAssetState; } - - add() - .name(rule.name) - .description(rule.description) - .priority(rule.priority) - .when(facts -> { - Object result; - try { - result = condition.evaluate(facts); - } catch (Exception ex) { - throw new RuntimeException("Error evaluating condition of rule '" + rule.name + "': " + ex.getMessage(), ex); - } - if (result instanceof Boolean) { - return result; - } else { - throw new IllegalArgumentException("Error evaluating condition of rule '" + rule.name + "': result is not boolean but " + result); - } - }) - .then(action); - - Condition resetCondition = buildResetCondition(rule, assetStatePredicate); - - - return this; } - protected Condition buildLhsCondition(Rule rule, Predicate assetStatePredicate) { - if (rule.when == null || (rule.when.asset == null && rule.when.timer == null)) { - return facts -> false; - } - - Predicate whenPredicate = facts -> false; - Predicate andPredicate = facts -> true; - - if (rule.when.asset != null) { - - - whenPredicate = facts -> { - - List assetStates = new ArrayList<>(facts.getAssetStates()); - - assetStates.removeIf(as -> assetStatePredicate.negate().test(as)); - - if (assetStates.isEmpty()) { - return false; - } - - // Apply reset predicate (to prevent re-running a rule on an asset state before the reset has triggered) - assetStates.removeIf(as -> { - String factName = buildResetFactName(rule, as); - return facts.getOptional(factName).isPresent(); - }); - if (assetStates.isEmpty()) { - return false; - } - - if (rule.when.asset.matchOrder != null) { - assetStates.sort(asComparator(rule.when.asset.matchOrder)); - } + static class Targets { + public TargetType type; + public List ids; - if (rule.when.asset.matchLimit > 0) { - int limit = Math.min(assetStates.size(), rule.when.asset.matchLimit); - assetStates = assetStates.subList(0, limit); - } - - // Push matched asset states into RHS - facts.bind("assetStates", assetStates); - - if (!assetStates.isEmpty()) { - // Push matched asset states into RHS - facts.bind("assetStates", assetStates); - return true; - } - - return false; - }; - } else if (!TextUtil.isNullOrEmpty(rule.when.timer)) { - // TODO: Create timer condition - } - - if (rule.and != null) { - andPredicate = asPredicate(timerService, assetStorageService, rule.and); + public Targets(TargetType type, List ids) { + this.type = type; + this.ids = ids; } - - - Predicate finalWhenPredicate = whenPredicate; - Predicate finalAndPredicate = andPredicate; - - return facts -> { - if (!finalWhenPredicate.test(facts)) { - return false; - } - - if (!finalAndPredicate.test(facts)) { - return false; - } - - return true; - }; } - // TODO: Implement action - protected Action buildAction(Rule rule) { - // If reset then add RuleFireInfo as temporary fact - return facts -> {}; + enum TargetType { + ASSET, + USER } - protected Condition buildResetCondition(Rule rule, Predicate assetStatePredicate) { - - // Timer only reset is handled in rule action with temporary fact other resets require rule.when.asset to be set - // so that we have - if (rule.reset == null || assetStatePredicate == null - || (!rule.reset.triggerNoLongerMatches && rule.reset.attributeTimestampChange == null - && rule.reset.attributeValueChange == null)) { - - return null; - } - - RuleTriggerReset reset = rule.reset; - - Predicate noLongerMatchesPredicate = ruleFiredInfo -> false; - BiPredicate attributeTimestampPredicate = (assetState, ruleFiredInfo) -> false; - BiPredicate attributeValuePredicate = (assetState, ruleFiredInfo) -> false; - - if (reset.triggerNoLongerMatches) { - noLongerMatchesPredicate = assetState -> assetStatePredicate.negate().test(assetState); - } - - if (reset.attributeTimestampChange != null) { - attributeTimestampPredicate = (assetState, ruleFiredInfo) -> { - long firedTimestamp = ruleFiredInfo.firedAssetState.getTimestamp(); - long currentTimestamp = assetState.getTimestamp(); - return firedTimestamp != currentTimestamp; - }; - } - - if (reset.attributeValueChange != null) { - attributeValuePredicate = (assetState, ruleFiredInfo) -> { - Value firedValue = ruleFiredInfo.firedAssetState.getValue().orElse(null); - Value currentValue = assetState.getValue().orElse(null); - return !Objects.equals(firedValue, currentValue); - }; - } - - Predicate finalNoLongerMatchesPredicate = noLongerMatchesPredicate; - BiPredicate finalAttributeTimestampPredicate = attributeTimestampPredicate; - BiPredicate finalAttributeValuePredicate = attributeValuePredicate; - - BiPredicate resetPredicate = (assetState, ruleFiredInfo) -> - finalNoLongerMatchesPredicate.test(assetState) - || finalAttributeTimestampPredicate.test(assetState, ruleFiredInfo) - || finalAttributeValuePredicate.test(assetState, ruleFiredInfo); - - return facts -> { - List resetAssetStates = facts.getAssetStates() - .stream() - .filter(as -> { - - String factName = buildResetFactName(rule, as); - Optional firedInfo = facts.getOptional(factName); - - return firedInfo.filter(ruleFiredInfo -> resetPredicate.test(as, ruleFiredInfo)).isPresent(); - - }) - .map(as -> buildResetFactName(rule, as)) - .collect(Collectors.toList()); - - if (resetAssetStates.isEmpty()) { - return false; - } - - facts.bind("resetAssetStates", resetAssetStates); - return true; - }; - } + final protected AssetStorageService assetStorageService; + final protected TimerService timerService; + final protected Assets assetsFacade; + final protected Users usersFacade; + final protected NotificationsFacade notificationFacade; + final protected ManagerExecutorService executorService; + final protected BiConsumer scheduledActionConsumer; - protected Action buildResetAction() { - return facts -> { - List resetAssets = facts.bound("resetAssetStates"); - if (resetAssets != null) { - resetAssets.forEach(facts::remove); - } - }; + public JsonRulesBuilder(TimerService timerService, AssetStorageService assetStorageService, ManagerExecutorService executorService, Assets assetsFacade, Users usersFacade, NotificationsFacade notificationFacade, BiConsumer scheduledActionConsumer) { + this.timerService = timerService; + this.assetStorageService = assetStorageService; + this.executorService = executorService; + this.assetsFacade = assetsFacade; + this.usersFacade = usersFacade; + this.notificationFacade = notificationFacade; + this.scheduledActionConsumer = scheduledActionConsumer; } protected static Predicate asPredicate(TimerService timerService, AssetStorageService assetStorageService, RuleCondition condition) { @@ -264,15 +97,15 @@ protected static Predicate asPredicate(TimerService timerService, As if (condition.predicates != null && condition.predicates.length > 0) { assetPredicates.addAll( - Arrays.stream(condition.predicates) - .map(p -> { - Predicate assetStatePredicate = asPredicate(timerService, assetStorageService, p); - return (Predicate) facts -> { - List assetStates = new ArrayList<>(facts.getAssetStates()); - assetStates.removeIf(as -> assetStatePredicate.negate().test(as)); - return !assetStates.isEmpty(); - }; - }).collect(Collectors.toList()) + Arrays.stream(condition.predicates) + .map(p -> { + Predicate assetStatePredicate = asPredicate(timerService, assetStorageService, p); + return (Predicate) facts -> { + List assetStates = new ArrayList<>(facts.getAssetStates()); + assetStates.removeIf(as -> assetStatePredicate.negate().test(as)); + return !assetStates.isEmpty(); + }; + }).collect(Collectors.toList()) ); } @@ -410,7 +243,7 @@ public static Predicate asPredicate(Supplier currentMillsProdu return AssetQueryPredicate.asPredicate(currentMillsProducer, pred.lastValue).test(value); }; - return assetState -> namePredicate.test(assetState.getName()) + return assetState -> namePredicate.test(assetState.getAttributeName()) && metaPredicate.test(assetState.getMeta()) && valuePredicate.test(assetState.getValue().orElse(null)) && oldValuePredicate.test(assetState.getOldValue().orElse(null)); @@ -481,4 +314,339 @@ protected static Predicate asPredicate(List> predicates, Rul protected static String buildResetFactName(Rule rule, AssetState assetState) { return rule.name + "_" + assetState.getId() + "_" + assetState.getAttributeName(); } + + protected static List getUserIds(Users users, UserQuery userQuery) { + return users.query() + .tenant(userQuery.tenantPredicate) + .assetPath(userQuery.pathPredicate) + .asset(userQuery.assetPredicate) + .limit(userQuery.limit) + .getResults(); + } + + protected static List getAssetIds(Assets assets, AssetQuery assetQuery) { + return assets.query() + .ids(assetQuery.ids) + .name(assetQuery.name) + .parent(assetQuery.parent) + .path(assetQuery.path) + .type(assetQuery.type) + .attributes(assetQuery.attribute) + .attributeMeta(assetQuery.attributeMeta) + .stream() + .collect(Collectors.toList()); + } + + public JsonRulesBuilder add(Rule rule) { + + final Predicate assetStatePredicate = rule.when != null && rule.when.asset != null + ? asPredicate(timerService, assetStorageService, rule.when.asset) : null; + + Condition condition = buildLhsCondition(rule, assetStatePredicate); + Action action = buildAction(rule); + + if (condition == null || action == null) { + throw new IllegalArgumentException("Error building JSON rule '" + rule.name + "'"); + } + + add().name(rule.name) + .description(rule.description) + .priority(rule.priority) + .when(facts -> { + Object result; + try { + result = condition.evaluate(facts); + } catch (Exception ex) { + throw new RuntimeException("Error evaluating condition of rule '" + rule.name + "': " + ex.getMessage(), ex); + } + if (result instanceof Boolean) { + return result; + } else { + throw new IllegalArgumentException("Error evaluating condition of rule '" + rule.name + "': result is not boolean but " + result); + } + }) + .then(action); + + Condition resetCondition = buildResetCondition(rule, assetStatePredicate); + Action resetAction = buildResetAction(); + + if (resetCondition != null && resetAction != null) { + add().name(rule.name + " RESET") + .description(rule.description + " Reset") + .priority(rule.priority + 1) + .when(facts -> { + Object result; + try { + result = resetCondition.evaluate(facts); + } catch (Exception ex) { + throw new RuntimeException("Error evaluating condition of rule '" + rule.name + " RESET': " + ex.getMessage(), ex); + } + if (result instanceof Boolean) { + return result; + } else { + throw new IllegalArgumentException("Error evaluating condition of rule '" + rule.name + " RESET': result is not boolean but " + result); + } + }) + .then(resetAction); + } + + return this; + } + + protected Condition buildLhsCondition(Rule rule, Predicate assetStatePredicate) { + if (rule.when == null || (rule.when.asset == null && rule.when.timer == null)) { + return facts -> false; + } + + Predicate whenPredicate = facts -> false; + Predicate andPredicate = facts -> true; + + if (rule.when.asset != null) { + + + whenPredicate = facts -> { + + List assetStates = new ArrayList<>(facts.getAssetStates()); + + assetStates.removeIf(as -> assetStatePredicate.negate().test(as)); + + if (assetStates.isEmpty()) { + return false; + } + + // Apply reset predicate (to prevent re-running a rule on an asset state before the reset has triggered) + assetStates.removeIf(as -> { + String factName = buildResetFactName(rule, as); + return facts.getOptional(factName).isPresent(); + }); + if (assetStates.isEmpty()) { + return false; + } + + if (rule.when.asset.matchOrder != null) { + assetStates.sort(asComparator(rule.when.asset.matchOrder)); + } + + if (rule.when.asset.matchLimit > 0) { + int limit = Math.min(assetStates.size(), rule.when.asset.matchLimit); + assetStates = assetStates.subList(0, limit); + } + + // Push matched asset states into RHS + facts.bind("assetStates", assetStates); + + if (!assetStates.isEmpty()) { + // Push matched asset states into RHS + facts.bind("assetStates", assetStates); + return true; + } + + return false; + }; + } else if (!TextUtil.isNullOrEmpty(rule.when.timer)) { + // TODO: Create timer condition + } + + if (rule.and != null) { + andPredicate = asPredicate(timerService, assetStorageService, rule.and); + } + + + Predicate finalWhenPredicate = whenPredicate; + Predicate finalAndPredicate = andPredicate; + + return facts -> { + if (!finalWhenPredicate.test(facts)) { + return false; + } + + if (!finalAndPredicate.test(facts)) { + return false; + } + + return true; + }; + } + + protected Action buildAction(Rule rule) { + + return facts -> { + + List assetStates = facts.bound("assetStates"); + + if (assetStates != null) { + for (AssetState assetState : assetStates) { + + // Add RuleFiredInfo fact to limit re-triggering of the rule for a given asset state + if (rule.reset == null || TextUtil.isNullOrEmpty(rule.reset.timer)) { + facts.put(buildResetFactName(rule, assetState), new RuleFiredInfo(assetState)); + } else if (!TextUtil.isNullOrEmpty(rule.reset.timer)) { + facts.putTemporary(buildResetFactName(rule, assetState), rule.reset.timer, new RuleFiredInfo(assetState)); + } + } + } + + if (rule.then == null) { + return; + } + + long delay = 0; + + for (RuleAction ruleAction : rule.then) { + + Runnable action = null; + + if (ruleAction instanceof RuleActionNotification) { + + RuleActionNotification notificationAction = (RuleActionNotification) ruleAction; + + if (notificationAction.notification != null) { + + // Override the notification targets if set in the rule + if (notificationAction.target != null) { + RuleActionWithTarget.Target target = notificationAction.target; + Notification.TargetType targetType = Notification.TargetType.ASSET; + List ids = null; + + if (target.useAssetsFromWhen && assetStates != null) { + ids = assetStates.stream().map(AssetState::getId).collect(Collectors.toList()); + } else if (target.assets != null) { + ids = getAssetIds(assetsFacade, target.assets); + } else if (target.users != null) { + targetType = Notification.TargetType.USER; + ids = getUserIds(usersFacade, target.users); + } + + if (ids != null) { + notificationAction.notification.setTargets( + new Notification.Targets( + targetType, + ids)); + } + } + + action = () -> notificationFacade.send(notificationAction.notification); + } + } else if (ruleAction instanceof RuleActionWriteAttribute) { + + RuleActionWriteAttribute attributeAction = (RuleActionWriteAttribute) ruleAction; + + if (!TextUtil.isNullOrEmpty(attributeAction.attributeName)) { + + RuleActionWithTarget.Target target = attributeAction.target; + List ids = null; + + if (target != null) { + + // Only assets make sense as the target + if (target.useAssetsFromWhen && assetStates != null) { + ids = assetStates.stream().map(AssetState::getId).collect(Collectors.toList()); + } else if (target.assets != null) { + ids = getAssetIds(assetsFacade, target.assets); + } + } + + if (ids != null) { + List finalIds = ids; + action = () -> + finalIds.forEach(id -> + assetsFacade.dispatch(id, attributeAction.attributeName, attributeAction.value)); + } + } + } else if (ruleAction instanceof RuleActionWait) { + long millis = ((RuleActionWait) ruleAction).millis; + if (millis > 0) { + delay += millis; + } + } + + if (action != null) { + if (delay > 0) { + scheduledActionConsumer.accept(action, delay); + } else { + action.run(); + } + } + } + + }; + } + + protected Condition buildResetCondition(Rule rule, Predicate assetStatePredicate) { + + // Timer only reset is handled in rule action with temporary fact other resets require rule.when.asset to be set + // so that we have + if (rule.reset == null || assetStatePredicate == null + || (!rule.reset.triggerNoLongerMatches && !rule.reset.attributeTimestampChange + && !rule.reset.attributeValueChange)) { + + return null; + } + + RuleTriggerReset reset = rule.reset; + + Predicate noLongerMatchesPredicate = ruleFiredInfo -> false; + BiPredicate attributeTimestampPredicate = (assetState, ruleFiredInfo) -> false; + BiPredicate attributeValuePredicate = (assetState, ruleFiredInfo) -> false; + + if (reset.triggerNoLongerMatches) { + noLongerMatchesPredicate = assetState -> assetStatePredicate.negate().test(assetState); + } + + if (reset.attributeTimestampChange) { + attributeTimestampPredicate = (assetState, ruleFiredInfo) -> { + long firedTimestamp = ruleFiredInfo.firedAssetState.getTimestamp(); + long currentTimestamp = assetState.getTimestamp(); + return firedTimestamp != currentTimestamp; + }; + } + + if (reset.attributeValueChange) { + attributeValuePredicate = (assetState, ruleFiredInfo) -> { + Value firedValue = ruleFiredInfo.firedAssetState.getValue().orElse(null); + Value currentValue = assetState.getValue().orElse(null); + return !Objects.equals(firedValue, currentValue); + }; + } + + Predicate finalNoLongerMatchesPredicate = noLongerMatchesPredicate; + BiPredicate finalAttributeTimestampPredicate = attributeTimestampPredicate; + BiPredicate finalAttributeValuePredicate = attributeValuePredicate; + + BiPredicate resetPredicate = (assetState, ruleFiredInfo) -> + finalNoLongerMatchesPredicate.test(assetState) + || finalAttributeTimestampPredicate.test(assetState, ruleFiredInfo) + || finalAttributeValuePredicate.test(assetState, ruleFiredInfo); + + return facts -> { + List resetAssetStates = facts.getAssetStates() + .stream() + .filter(as -> { + + String factName = buildResetFactName(rule, as); + Optional firedInfo = facts.getOptional(factName); + + return firedInfo.filter(ruleFiredInfo -> resetPredicate.test(as, ruleFiredInfo)).isPresent(); + + }) + .map(as -> buildResetFactName(rule, as)) + .collect(Collectors.toList()); + + if (resetAssetStates.isEmpty()) { + return false; + } + + facts.bind("resetAssetStates", resetAssetStates); + return true; + }; + } + + protected Action buildResetAction() { + return facts -> { + List resetAssets = facts.bound("resetAssetStates"); + if (resetAssets != null) { + resetAssets.forEach(facts::remove); + } + }; + } } diff --git a/manager/src/main/java/org/openremote/manager/rules/RulesEngine.java b/manager/src/main/java/org/openremote/manager/rules/RulesEngine.java index 3ed0be056b..3cc5f903a2 100644 --- a/manager/src/main/java/org/openremote/manager/rules/RulesEngine.java +++ b/manager/src/main/java/org/openremote/manager/rules/RulesEngine.java @@ -208,14 +208,15 @@ public void addRuleset(T ruleset) { // Check if ruleset is already deployed (maybe an older version) if (deployment != null) { + deployment.stop(); LOG.info("Removing ruleset deployment: " + ruleset); deployments.remove(ruleset.getId()); updateDeploymentInfo(); } - deployment = new RulesetDeployment(ruleset, timerService, assetStorageService); + deployment = new RulesetDeployment(ruleset, timerService, assetStorageService, executorService, assetsFacade, usersFacade, notificationFacade); - boolean compilationSuccessful = deployment.registerRules(ruleset, assetsFacade, usersFacade, notificationFacade); + boolean compilationSuccessful = deployment.start(); if (!compilationSuccessful) { // If any other ruleset is DEPLOYED in this scope, demote to READY @@ -268,7 +269,8 @@ public boolean removeRuleset(Ruleset ruleset) { stop(); - deployments.remove(ruleset.getId()); + RulesetDeployment deployment = deployments.remove(ruleset.getId()); + deployment.stop(); updateDeploymentInfo(); publishRulesetStatus(ruleset, ruleset.isEnabled() ? REMOVED : DISABLED, null); diff --git a/manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java b/manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java index bf1d8ba454..38f3951222 100644 --- a/manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java +++ b/manager/src/main/java/org/openremote/manager/rules/RulesetDeployment.java @@ -34,6 +34,7 @@ import org.openremote.container.Container; import org.openremote.container.timer.TimerService; import org.openremote.manager.asset.AssetStorageService; +import org.openremote.manager.concurrent.ManagerExecutorService; import org.openremote.manager.rules.facade.NotificationsFacade; import org.openremote.model.rules.Assets; import org.openremote.model.rules.Ruleset; @@ -43,14 +44,18 @@ import javax.script.*; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; import java.util.logging.Level; import java.util.logging.Logger; -public class RulesetDeployment { +import static org.openremote.container.concurrent.GlobalLock.withLock; - public static final int DEFAULT_RULE_PRIORITY = 1000; +public class RulesetDeployment { /** * An interface that looks like a JavaScript browser console, for simplified logging. @@ -87,7 +92,7 @@ public Object filterReceiver(Object receiver) { throw new SecurityException("Not allowed: " + receiver); } } - + public static final int DEFAULT_RULE_PRIORITY = 1000; // Share one JS script engine manager, it's thread-safe static final protected ScriptEngineManager scriptEngineManager; @@ -112,7 +117,7 @@ public Object filterReceiver(Object receiver) { then check your class loader setup. */ groovyShell = new GroovyShell( - new CompilerConfiguration().addCompilationCustomizers(new SandboxTransformer()) + new CompilerConfiguration().addCompilationCustomizers(new SandboxTransformer()) ); } @@ -120,13 +125,22 @@ then check your class loader setup. final protected Rules rules = new Rules(); final protected AssetStorageService assetStorageService; final protected TimerService timerService; + final protected ManagerExecutorService executorService; + final protected Assets assetsFacade; + final protected Users usersFacade; + final protected NotificationsFacade notificationFacade; + final protected List scheduledRuleActions = new ArrayList<>(); protected RulesetStatus status; protected Throwable error; - public RulesetDeployment(Ruleset ruleset, TimerService timerService, AssetStorageService assetStorageService) { + public RulesetDeployment(Ruleset ruleset, TimerService timerService, AssetStorageService assetStorageService, ManagerExecutorService executorService, Assets assetsFacade, Users usersFacade, NotificationsFacade notificationFacade) { this.ruleset = ruleset; this.timerService = timerService; this.assetStorageService = assetStorageService; + this.executorService = executorService; + this.assetsFacade = assetsFacade; + this.usersFacade = usersFacade; + this.notificationFacade = notificationFacade; } public long getId() { @@ -149,28 +163,51 @@ public Rules getRules() { return rules; } - public boolean registerRules(Ruleset ruleset, Assets assetsFacade, Users usersFacade, NotificationsFacade notificationFacade) { + public boolean start() { RulesEngine.LOG.info("Evaluating ruleset deployment: " + ruleset); switch (ruleset.getLang()) { case JAVASCRIPT: - return registerRulesJavascript(ruleset, assetsFacade, usersFacade, notificationFacade); + return startRulesJavascript(ruleset, assetsFacade, usersFacade, notificationFacade); case GROOVY: - return registerRulesGroovy(ruleset, assetsFacade, usersFacade, notificationFacade); + return startRulesGroovy(ruleset, assetsFacade, usersFacade, notificationFacade); case JSON: - return registerRulesJson(ruleset); + return startRulesJson(ruleset, assetsFacade, usersFacade, notificationFacade); } return false; } - private boolean registerRulesJson(Ruleset ruleset) { + /** + * Called when this deployment is stopped, could be the ruleset is being updated, removed or an error has occurred + * during execution + */ + public void stop() { + withLock(toString() + "::stopRulesetDeployment", () -> + scheduledRuleActions.removeIf(scheduledFuture -> { + scheduledFuture.cancel(true); + return true; + })); + } + + protected void scheduleRuleAction(Runnable action, long delayMillis) { + withLock(toString() + "::scheduleRuleAction", () -> { + ScheduledFuture future = executorService.schedule(() -> + withLock(toString() + "::scheduledRuleActionFire", () -> { + scheduledRuleActions.removeIf(Future::isDone); + action.run(); + }), delayMillis); + scheduledRuleActions.add(future); + }); + } + + protected boolean startRulesJson(Ruleset ruleset, Assets assetsFacade, Users usersFacade, NotificationsFacade notificationFacade) { try { JsonRulesetDefinition jsonRulesetDefinition = Container.JSON.readValue(ruleset.getRules(), JsonRulesetDefinition.class); - JsonRulesBuilder jsonRulesBuilder = new JsonRulesBuilder(timerService, assetStorageService); + JsonRulesBuilder jsonRulesBuilder = new JsonRulesBuilder(timerService, assetStorageService, executorService, assetsFacade, usersFacade, notificationFacade, this::scheduleRuleAction); Arrays.stream(jsonRulesetDefinition.rules).forEach(jsonRulesBuilder::add); for (Rule rule : jsonRulesBuilder.build()) { - RulesEngine.LOG.info("Registering simple rule: " + rule.getName()); + RulesEngine.LOG.info("Registering JSON rule: " + rule.getName()); rules.register(rule); } RulesEngine.LOG.info("Evaluated ruleset deployment: " + ruleset); @@ -184,7 +221,7 @@ private boolean registerRulesJson(Ruleset ruleset) { } } - public boolean registerRulesJavascript(Ruleset ruleset, Assets assetsFacade, Users usersFacade, NotificationsFacade consolesFacade) { + protected boolean startRulesJavascript(Ruleset ruleset, Assets assetsFacade, Users usersFacade, NotificationsFacade consolesFacade) { // TODO https://github.com/pfisterer/scripting-sandbox/blob/master/src/main/java/de/farberg/scripting/sandbox/ScriptingSandbox.java ScriptEngine scriptEngine = scriptEngineManager.getEngineByName("nashorn"); ScriptContext newContext = new SimpleScriptContext(); @@ -199,54 +236,54 @@ public boolean registerRulesJavascript(Ruleset ruleset, Assets assetsFacade, Use // Default header/imports for all rules scripts script = "load(\"nashorn:mozilla_compat.js\");\n" + // This provides importPackage - "\n" + - "importPackage(\n" + - " \"java.util.stream\",\n" + - " \"org.openremote.model.asset\",\n" + - " \"org.openremote.model.attribute\",\n" + - " \"org.openremote.model.value\",\n" + - " \"org.openremote.model.rules\",\n" + - " \"org.openremote.model.query\"\n" + - ");\n" + - "var Match = Java.type(\"org.openremote.model.query.BaseAssetQuery$Match\");\n" + - "var Operator = Java.type(\"org.openremote.model.query.BaseAssetQuery$Operator\");\n" + - "var NumberType = Java.type(\"org.openremote.model.query.BaseAssetQuery$NumberType\");\n" + - "var StringPredicate = Java.type(\"org.openremote.model.query.filter.StringPredicate\");\n" + - "var BooleanPredicate = Java.type(\"org.openremote.model.query.filter.BooleanPredicate\");\n" + - "var StringArrayPredicate = Java.type(\"org.openremote.model.query.filter.StringArrayPredicate\");\n" + - "var DateTimePredicate = Java.type(\"org.openremote.model.query.filter.DateTimePredicate\");\n" + - "var NumberPredicate = Java.type(\"org.openremote.model.query.filter.NumberPredicate\");\n" + - "var ParentPredicate = Java.type(\"org.openremote.model.query.filter.ParentPredicate\");\n" + - "var PathPredicate = Java.type(\"org.openremote.model.query.filter.PathPredicate\");\n" + - "var TenantPredicate = Java.type(\"org.openremote.model.query.filter.TenantPredicate\");\n" + - "var AttributePredicate = Java.type(\"org.openremote.model.query.filter.AttributePredicate\");\n" + - "var AttributeExecuteStatus = Java.type(\"org.openremote.model.attribute.AttributeExecuteStatus\");\n" + - "var EXACT = Match.EXACT;\n" + - "var BEGIN = Match.BEGIN;\n" + - "var END = Match.END;\n" + - "var CONTAINS = Match.CONTAINS;\n" + - "var EQUALS = Operator.EQUALS;\n" + - "var GREATER_THAN = Operator.GREATER_THAN;\n" + - "var GREATER_EQUALS = Operator.GREATER_EQUALS;\n" + - "var LESS_THAN = Operator.LESS_THAN;\n" + - "var LESS_EQUALS = Operator.LESS_EQUALS;\n" + - "var BETWEEN = Operator.BETWEEN;\n" + - "var REQUEST_START = AttributeExecuteStatus.REQUEST_START;\n" + - "var REQUEST_REPEATING = AttributeExecuteStatus.REQUEST_REPEATING;\n" + - "var REQUEST_CANCEL = AttributeExecuteStatus.REQUEST_CANCEL;\n" + - "var READY = AttributeExecuteStatus.READY;\n" + - "var COMPLETED = AttributeExecuteStatus.COMPLETED;\n" + - "var RUNNING = AttributeExecuteStatus.RUNNING;\n" + - "var CANCELLED = AttributeExecuteStatus.CANCELLED;\n" + - "var ERROR = AttributeExecuteStatus.ERROR;\n" + - "var DISABLED = AttributeExecuteStatus.DISABLED;\n" + - "\n" - + script; + "\n" + + "importPackage(\n" + + " \"java.util.stream\",\n" + + " \"org.openremote.model.asset\",\n" + + " \"org.openremote.model.attribute\",\n" + + " \"org.openremote.model.value\",\n" + + " \"org.openremote.model.rules\",\n" + + " \"org.openremote.model.query\"\n" + + ");\n" + + "var Match = Java.type(\"org.openremote.model.query.BaseAssetQuery$Match\");\n" + + "var Operator = Java.type(\"org.openremote.model.query.BaseAssetQuery$Operator\");\n" + + "var NumberType = Java.type(\"org.openremote.model.query.BaseAssetQuery$NumberType\");\n" + + "var StringPredicate = Java.type(\"org.openremote.model.query.filter.StringPredicate\");\n" + + "var BooleanPredicate = Java.type(\"org.openremote.model.query.filter.BooleanPredicate\");\n" + + "var StringArrayPredicate = Java.type(\"org.openremote.model.query.filter.StringArrayPredicate\");\n" + + "var DateTimePredicate = Java.type(\"org.openremote.model.query.filter.DateTimePredicate\");\n" + + "var NumberPredicate = Java.type(\"org.openremote.model.query.filter.NumberPredicate\");\n" + + "var ParentPredicate = Java.type(\"org.openremote.model.query.filter.ParentPredicate\");\n" + + "var PathPredicate = Java.type(\"org.openremote.model.query.filter.PathPredicate\");\n" + + "var TenantPredicate = Java.type(\"org.openremote.model.query.filter.TenantPredicate\");\n" + + "var AttributePredicate = Java.type(\"org.openremote.model.query.filter.AttributePredicate\");\n" + + "var AttributeExecuteStatus = Java.type(\"org.openremote.model.attribute.AttributeExecuteStatus\");\n" + + "var EXACT = Match.EXACT;\n" + + "var BEGIN = Match.BEGIN;\n" + + "var END = Match.END;\n" + + "var CONTAINS = Match.CONTAINS;\n" + + "var EQUALS = Operator.EQUALS;\n" + + "var GREATER_THAN = Operator.GREATER_THAN;\n" + + "var GREATER_EQUALS = Operator.GREATER_EQUALS;\n" + + "var LESS_THAN = Operator.LESS_THAN;\n" + + "var LESS_EQUALS = Operator.LESS_EQUALS;\n" + + "var BETWEEN = Operator.BETWEEN;\n" + + "var REQUEST_START = AttributeExecuteStatus.REQUEST_START;\n" + + "var REQUEST_REPEATING = AttributeExecuteStatus.REQUEST_REPEATING;\n" + + "var REQUEST_CANCEL = AttributeExecuteStatus.REQUEST_CANCEL;\n" + + "var READY = AttributeExecuteStatus.READY;\n" + + "var COMPLETED = AttributeExecuteStatus.COMPLETED;\n" + + "var RUNNING = AttributeExecuteStatus.RUNNING;\n" + + "var CANCELLED = AttributeExecuteStatus.CANCELLED;\n" + + "var ERROR = AttributeExecuteStatus.ERROR;\n" + + "var DISABLED = AttributeExecuteStatus.DISABLED;\n" + + "\n" + + script; try { scriptEngine.eval(script, engineScope); - registerRulesJavascript((ScriptObjectMirror) engineScope.get("rules")); + startRulesJavascript((ScriptObjectMirror) engineScope.get("rules")); RulesEngine.LOG.info("Evaluated ruleset deployment: " + ruleset); return true; @@ -262,7 +299,7 @@ public boolean registerRulesJavascript(Ruleset ruleset, Assets assetsFacade, Use /** * Marshal the JavaScript rules array into {@link Rule} instances. */ - protected void registerRulesJavascript(ScriptObjectMirror scriptRules) { + protected void startRulesJavascript(ScriptObjectMirror scriptRules) { if (scriptRules == null || !scriptRules.isArray()) { throw new IllegalArgumentException("No 'rules' array defined in ruleset"); } @@ -323,12 +360,12 @@ protected void registerRulesJavascript(ScriptObjectMirror scriptRules) { RulesEngine.LOG.info("Registering rule: " + name); rules.register( - new RuleBuilder().name(name).description(description).priority(priority).when(when).then(then).build() + new RuleBuilder().name(name).description(description).priority(priority).when(when).then(then).build() ); } } - public boolean registerRulesGroovy(Ruleset ruleset, Assets assetsFacade, Users usersFacade, NotificationsFacade notificationFacade) { + protected boolean startRulesGroovy(Ruleset ruleset, Assets assetsFacade, Users usersFacade, NotificationsFacade notificationFacade) { try { // TODO Implement sandbox // new DenyAll().register(); @@ -368,21 +405,21 @@ public Throwable getError() { return error; } - public String getErrorMessage() { - return getError() != null ? getError().getMessage() : null; - } - public void setError(Throwable error) { this.error = error; } + public String getErrorMessage() { + return getError() != null ? getError().getMessage() : null; + } + @Override public String toString() { return getClass().getSimpleName() + "{" + - "id=" + getId() + - ", name='" + getName() + '\'' + - ", version=" + getVersion() + - ", status=" + status + - '}'; + "id=" + getId() + + ", name='" + getName() + '\'' + + ", version=" + getVersion() + + ", status=" + status + + '}'; } } diff --git a/model/src/main/java/org/openremote/model/query/filter/ParentPredicate.java b/model/src/main/java/org/openremote/model/query/filter/ParentPredicate.java index d5ad625280..a7df8bc911 100644 --- a/model/src/main/java/org/openremote/model/query/filter/ParentPredicate.java +++ b/model/src/main/java/org/openremote/model/query/filter/ParentPredicate.java @@ -25,6 +25,7 @@ public class ParentPredicate { public String id; public String type; + public String name; public boolean noParent; public ParentPredicate() { @@ -52,6 +53,11 @@ public ParentPredicate type(AssetType type) { return type(type.getValue()); } + public ParentPredicate name(String name) { + this.name = name; + return this; + } + public ParentPredicate noParent(boolean noParent) { this.noParent = noParent; return this; diff --git a/model/src/main/java/org/openremote/model/rules/json/Rule.java b/model/src/main/java/org/openremote/model/rules/json/Rule.java index 62ba741e79..1d72271239 100644 --- a/model/src/main/java/org/openremote/model/rules/json/Rule.java +++ b/model/src/main/java/org/openremote/model/rules/json/Rule.java @@ -43,7 +43,7 @@ *

* {@link #reset}* - Optional logic used to reset the rule, this is applied to each * {@link org.openremote.model.rules.AssetState} that was returned by the {@link #when}. If no reset is specified then - * it is assumed that the rule is fire once per matched {@link org.openremote.model.rules.AssetState}. + * it is assumed that the rule is fire once only per matched {@link org.openremote.model.rules.AssetState}. * NOTE: Rule trigger history is not persisted so on system restart this information is lost and a rule will be * able to fire again. *

diff --git a/model/src/main/java/org/openremote/model/rules/json/RuleActionNotification.java b/model/src/main/java/org/openremote/model/rules/json/RuleActionNotification.java index 4da162fb22..99e4d6dc0a 100644 --- a/model/src/main/java/org/openremote/model/rules/json/RuleActionNotification.java +++ b/model/src/main/java/org/openremote/model/rules/json/RuleActionNotification.java @@ -19,8 +19,8 @@ */ package org.openremote.model.rules.json; -import org.openremote.model.notification.AbstractNotificationMessage; +import org.openremote.model.notification.Notification; public class RuleActionNotification extends RuleActionWithTarget { - protected AbstractNotificationMessage notification; + public Notification notification; } diff --git a/model/src/main/java/org/openremote/model/rules/json/RuleActionWait.java b/model/src/main/java/org/openremote/model/rules/json/RuleActionWait.java index d04578e553..14d6007c1d 100644 --- a/model/src/main/java/org/openremote/model/rules/json/RuleActionWait.java +++ b/model/src/main/java/org/openremote/model/rules/json/RuleActionWait.java @@ -20,5 +20,5 @@ package org.openremote.model.rules.json; public class RuleActionWait implements RuleAction { - protected long millis; + public long millis; } diff --git a/model/src/main/java/org/openremote/model/rules/json/RuleActionWithTarget.java b/model/src/main/java/org/openremote/model/rules/json/RuleActionWithTarget.java index 08d288117b..c1d6727129 100644 --- a/model/src/main/java/org/openremote/model/rules/json/RuleActionWithTarget.java +++ b/model/src/main/java/org/openremote/model/rules/json/RuleActionWithTarget.java @@ -22,13 +22,24 @@ import org.openremote.model.query.AssetQuery; import org.openremote.model.query.UserQuery; +/** + * Indicates that the action should be scoped to the specified {@link Target}. + */ public abstract class RuleActionWithTarget implements RuleAction { + /** + * Only one of the options should be set the precedence is: + *

    + *
  1. {@link #useAssetsFromWhen}
  2. + *
  3. {@link #assets}
  4. + *
  5. {@link #users}
  6. + *
+ */ public static class Target { - protected boolean useAssetsFromWhen; - protected AssetQuery assets; - protected UserQuery users; + public boolean useAssetsFromWhen; + public AssetQuery assets; + public UserQuery users; } - protected Target target; + public Target target; } diff --git a/model/src/main/java/org/openremote/model/rules/json/RuleActionWriteAttribute.java b/model/src/main/java/org/openremote/model/rules/json/RuleActionWriteAttribute.java index 3c04d648a3..544d8a9748 100644 --- a/model/src/main/java/org/openremote/model/rules/json/RuleActionWriteAttribute.java +++ b/model/src/main/java/org/openremote/model/rules/json/RuleActionWriteAttribute.java @@ -22,6 +22,6 @@ import org.openremote.model.value.Value; public class RuleActionWriteAttribute extends RuleActionWithTarget { - protected String attributeName; - protected Value value; + public String attributeName; + public Value value; } diff --git a/model/src/main/java/org/openremote/model/rules/json/RuleTriggerReset.java b/model/src/main/java/org/openremote/model/rules/json/RuleTriggerReset.java index d9320e76d0..aac35d46fb 100644 --- a/model/src/main/java/org/openremote/model/rules/json/RuleTriggerReset.java +++ b/model/src/main/java/org/openremote/model/rules/json/RuleTriggerReset.java @@ -19,8 +19,6 @@ */ package org.openremote.model.rules.json; -import org.openremote.model.query.filter.StringPredicate; - /** * This defines when an {@link org.openremote.model.rules.AssetState} becomes eligible for triggering the rule again once * if has triggered a rule. @@ -43,11 +41,11 @@ public class RuleTriggerReset { * When the timestamp of the {@link org.openremote.model.rules.AssetState} changes in comparison to the timestamp * at the time the rule fired. */ - public StringPredicate attributeTimestampChange; + public boolean attributeTimestampChange; /** * When the value of the {@link org.openremote.model.rules.AssetState} changes in comparison to the value at the * time the rule fired. */ - public StringPredicate attributeValueChange; + public boolean attributeValueChange; } diff --git a/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceLightsOnGeofenceTest.groovy b/test/src/test/groovy/org/openremote/test/rules/residence/JsonRulesTest.groovy similarity index 56% rename from test/src/test/groovy/org/openremote/test/rules/residence/ResidenceLightsOnGeofenceTest.groovy rename to test/src/test/groovy/org/openremote/test/rules/residence/JsonRulesTest.groovy index 0701b73f2e..ca3e51d959 100644 --- a/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceLightsOnGeofenceTest.groovy +++ b/test/src/test/groovy/org/openremote/test/rules/residence/JsonRulesTest.groovy @@ -1,8 +1,12 @@ package org.openremote.test.rules.residence - +import com.google.firebase.messaging.Message +import org.openremote.container.timer.TimerService import org.openremote.manager.asset.AssetProcessingService import org.openremote.manager.asset.AssetStorageService +import org.openremote.manager.notification.NotificationService +import org.openremote.manager.notification.PushNotificationHandler +import org.openremote.manager.rules.JsonRulesBuilder import org.openremote.manager.rules.RulesEngine import org.openremote.manager.rules.RulesService import org.openremote.manager.rules.RulesetStorageService @@ -15,6 +19,10 @@ import org.openremote.model.console.ConsoleProvider import org.openremote.model.console.ConsoleRegistration import org.openremote.model.console.ConsoleResource import org.openremote.model.geo.GeoJSONPoint +import org.openremote.model.notification.AbstractNotificationMessage +import org.openremote.model.notification.Notification +import org.openremote.model.notification.NotificationSendResult +import org.openremote.model.notification.PushNotificationMessage import org.openremote.model.rules.Ruleset import org.openremote.model.rules.TenantRuleset import org.openremote.model.value.ObjectValue @@ -22,24 +30,47 @@ import org.openremote.test.ManagerContainerTrait import spock.lang.Specification import spock.util.concurrent.PollingConditions +import static java.util.concurrent.TimeUnit.MINUTES import static org.openremote.manager.setup.builtin.ManagerDemoSetup.DEMO_RULE_STATES_CUSTOMER_A import static org.openremote.model.Constants.KEYCLOAK_CLIENT_ID import static org.openremote.model.attribute.AttributeType.LOCATION import static org.openremote.model.value.Values.parse -class ResidenceLightsOnGeofenceTest extends Specification implements ManagerContainerTrait { +class JsonRulesTest extends Specification implements ManagerContainerTrait { def "Turn all lights off when console exits the residence geofence"() { - given: "the container environment is started" + def notificationMessages = [] + def targetIds = [] + + given: "a mock push notification handler" + PushNotificationHandler mockPushNotificationHandler = Spy(PushNotificationHandler) { + isValid() >> true + + sendMessage(_ as Long, _ as Notification.Source, _ as String, _ as Notification.TargetType, _ as String, _ as AbstractNotificationMessage) >> { + id, source, sourceId, targetType, targetId, message -> + notificationMessages << message + targetIds << targetId + callRealMethod() + } + + // Assume sent to FCM + sendMessage(_ as Message) >> { + message -> return NotificationSendResult.success() + } + } + + and: "the container environment is started with the mock handler" def conditions = new PollingConditions(timeout: 10, delay: 1) def serverPort = findEphemeralPort() - def container = startContainerWithPseudoClock(defaultConfig(serverPort), defaultServices()) + def services = defaultServices() + ((NotificationService)services.find {it instanceof NotificationService}).notificationHandlerMap.put(PushNotificationMessage.TYPE, mockPushNotificationHandler) + def container = startContainerWithPseudoClock(defaultConfig(serverPort), services) def managerDemoSetup = container.getService(SetupService.class).getTaskOfType(ManagerDemoSetup.class) def keycloakDemoSetup = container.getService(SetupService.class).getTaskOfType(KeycloakDemoSetup.class) def rulesService = container.getService(RulesService.class) def rulesetStorageService = container.getService(RulesetStorageService.class) - def assetProcessingService = container.getService(AssetProcessingService.class) + def timerService = container.getService(TimerService.class) def assetStorageService = container.getService(AssetStorageService.class) RulesEngine customerAEngine @@ -47,7 +78,7 @@ class ResidenceLightsOnGeofenceTest extends Specification implements ManagerCont Ruleset ruleset = new TenantRuleset( "Demo Apartment - All Lights Off", keycloakDemoSetup.customerATenant.id, - getClass().getResource("/org/openremote/test/rules/BasicLocationPredicates.json").text, + getClass().getResource("/org/openremote/test/rules/BasicJsonRules.json").text, Ruleset.Lang.JSON ) rulesetStorageService.merge(ruleset) @@ -132,42 +163,68 @@ class ResidenceLightsOnGeofenceTest extends Specification implements ManagerCont } != null } - when: "the console moves more than 50m away from the apartment" + when: "the console device moves outside the home geofence (as defined in the rule)" authenticatedAssetResource.writeAttributeValue(null, consoleRegistration.id, LOCATION.name, new GeoJSONPoint(0d,0d).toValue().toJson()) - then: "" - -// when: "the ALL LIGHTS OFF push-button is pressed for an apartment" -// def lightsOffEvent = new AttributeEvent( -// managerDemoSetup.apartment2Id, "allLightsOffSwitch", Values.create(true), getClockTimeOf(container) -// ) -// assetProcessingService.sendAttributeEvent(lightsOffEvent) -// -// then: "the room lights in the apartment should be off" -// conditions.eventually { -// assert apartment2Engine.assetStates.size() == DEMO_RULE_STATES_APARTMENT_2 -// assert apartment2Engine.assetEvents.size() == 1 -// def livingroomAsset = assetStorageService.find(managerDemoSetup.apartment2LivingroomId, true) -// assert !livingroomAsset.getAttribute("lightSwitch").get().valueAsBoolean.get() -// def bathRoomAsset = assetStorageService.find(managerDemoSetup.apartment2BathroomId, true) -// assert !bathRoomAsset.getAttribute("lightSwitch").get().valueAsBoolean.get() -// } -// -// when: "time advanced by 15 seconds" -// advancePseudoClock(15, SECONDS, container) -// -// and: "we turn the light on again in a room" -// assetProcessingService.sendAttributeEvent( -// new AttributeEvent(managerDemoSetup.apartment2LivingroomId, "lightSwitch", Values.create(true)) -// ) -// -// then: "the light should still be on after a few seconds (the all lights off event expires after 3 seconds)" -// new PollingConditions(initialDelay: 3).eventually { -// assert apartment2Engine.assetStates.size() == DEMO_RULE_STATES_APARTMENT_2 -// assert apartment2Engine.assetEvents.size() == 0 -// def livingroomAsset = assetStorageService.find(managerDemoSetup.apartment2LivingroomId, true) -// assert livingroomAsset.getAttribute("lightSwitch").get().valueAsBoolean.get() -// } + then: "the apartment lights should be switched off" + conditions.eventually { + def livingroomAsset = assetStorageService.find(managerDemoSetup.apartment2LivingroomId, true) + assert !livingroomAsset.getAttribute("lightSwitch").get().valueAsBoolean.get() + def bathRoomAsset = assetStorageService.find(managerDemoSetup.apartment2BathroomId, true) + assert !bathRoomAsset.getAttribute("lightSwitch").get().valueAsBoolean.get() + } + + and: "a notification should have been sent to the console" + conditions.eventually { + assert notificationMessages.size() == 1 + assert targetIds[0] == consoleRegistration.id + } + + and: "the rule reset fact should have been created" + conditions.eventually { + assert customerAEngine.facts.getOptional("Test Rule_" + consoleRegistration.id + "_location").isPresent() + } + + and: "after a few seconds the rule should not have fired again" + new PollingConditions(initialDelay: 3).eventually { + assert notificationMessages.size() == 1 + } + + when: "the console device moves back inside the home geofence (as defined in the rule)" + authenticatedAssetResource.writeAttributeValue(null, consoleRegistration.id, LOCATION.name, ManagerDemoSetup.SMART_HOME_LOCATION.toValue().toJson()) + + then: "the rule reset fact should be removed" + conditions.eventually { + assert !customerAEngine.facts.getOptional("Test Rule_" + consoleRegistration.id + "_location").isPresent() + } + + when: "the console device moves outside the home geofence again (as defined in the rule)" + authenticatedAssetResource.writeAttributeValue(null, consoleRegistration.id, LOCATION.name, new GeoJSONPoint(0d,0d).toValue().toJson()) + + then: "another notification should have been sent to the console" + conditions.eventually { + assert notificationMessages.size() == 2 + assert targetIds[1] == consoleRegistration.id + } + + when: "the console sends a location update with the same location but a newer timestamp" + advancePseudoClock(35, MINUTES, container) + authenticatedAssetResource.writeAttributeValue(null, consoleRegistration.id, LOCATION.name, new GeoJSONPoint(0d,0d).toValue().toJson()) + + then: "another notification should have been sent to the console (because the reset condition includes reset on attributeTimestampChange)" + conditions.eventually { + assert notificationMessages.size() == 3 + assert targetIds[2] == consoleRegistration.id + } + + when: "the console sends a location update with a new location but still outside the geofence" + authenticatedAssetResource.writeAttributeValue(null, consoleRegistration.id, LOCATION.name, new GeoJSONPoint(10d,10d).toValue().toJson()) + + then: "another notification should have been sent to the console (because the reset condition includes reset on attributeValueChange)" + conditions.eventually { + assert notificationMessages.size() == 4 + assert targetIds[3] == consoleRegistration.id + } cleanup: "stop the container" stopContainer(container) diff --git a/test/src/test/resources/org/openremote/test/rules/BasicLocationPredicates.json b/test/src/test/resources/org/openremote/test/rules/BasicJsonRules.json similarity index 51% rename from test/src/test/resources/org/openremote/test/rules/BasicLocationPredicates.json rename to test/src/test/resources/org/openremote/test/rules/BasicJsonRules.json index 6937f65ec1..a13e3c1673 100644 --- a/test/src/test/resources/org/openremote/test/rules/BasicLocationPredicates.json +++ b/test/src/test/resources/org/openremote/test/rules/BasicJsonRules.json @@ -4,11 +4,13 @@ "name": "Test Rule", "when": { "asset": { - "type": { - "predicateType": "string", - "match": "EXACT", - "value": "urn:openremote:asset:console" - }, + "types": [ + { + "predicateType": "string", + "match": "EXACT", + "value": "urn:openremote:asset:console" + } + ], "attributes": { "predicates": [ { @@ -37,13 +39,36 @@ }, "notification": { "name": "test", - "type": "push", "message": { + "type": "push", "title": "Test title" } } + }, + { + "action": "write-attribute", + "target": { + "assets": { + "parent": { + "type": "urn:openremote:asset:residence", + "name": "Apartment 2" + }, + "type": { + "predicateType": "string", + "match": "EXACT", + "value": "urn:openremote:asset:room" + } + } + }, + "attributeName": "lightSwitch", + "value": false } - ] + ], + "reset": { + "triggerNoLongerMatches": true, + "attributeValueChange": true, + "attributeTimestampChange": true + } } ] } \ No newline at end of file