From 95d297b55bcb5703223af2d8e3e5f4c2c6ef3a45 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 15:59:04 +0200 Subject: [PATCH] rollback to previous geofencing design + bugfixes --- .../ctx/state/BaseCalculatedFieldState.java | 5 ++ .../cf/ctx/state/CalculatedFieldState.java | 16 ++++++- .../GeofencingCalculatedFieldState.java | 31 ++++++------ .../GeofencingCalculatedFieldStateTest.java | 47 ++++++++++++++----- .../PropagationCalculatedFieldStateTest.java | 33 ++++++++----- .../utils/CalculatedFieldUtilsTest.java | 2 +- 6 files changed, 93 insertions(+), 41 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 741c94c796..d6cf2bc845 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -25,6 +25,8 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; import org.thingsboard.server.utils.CalculatedFieldUtils; import java.io.Closeable; @@ -164,6 +166,9 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, .mapToLong(e -> (e instanceof SingleValueArgumentEntry s) ? s.getTs() : 0L) .max() .orElse(0L); + } else if (entry instanceof GeofencingArgumentEntry geofencingArgumentEntry) { + newTs = geofencingArgumentEntry.getZoneStates().values().stream() + .mapToLong(GeofencingZoneState::getTs).max().orElse(0L); } this.latestTimestamp = Math.max(this.latestTimestamp, newTs); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index e3914cc125..ec5681202f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -38,6 +38,7 @@ import java.io.Closeable; import java.util.List; import java.util.Map; +import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto; @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @@ -102,14 +103,25 @@ public interface CalculatedFieldState extends Closeable { record ReadinessStatus(boolean ready, String errorMsg) { - private static final String ERROR_MESSAGE = "Required arguments are missing: "; + private static final String MISSING_REQUIRED_ARGUMENTS_ERROR = "Required arguments are missing: "; + private static final String MISSING_PROPAGATION_TARGETS_ERROR = "No entities found via 'Propagation path to related entities'. " + + "Verify the relation type and direction configured."; + private static final String MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR = MISSING_PROPAGATION_TARGETS_ERROR + " Missing arguments to propagate: "; private static final ReadinessStatus READY = new ReadinessStatus(true, null); public static ReadinessStatus from(List emptyOrMissingArguments) { if (CollectionsUtil.isEmpty(emptyOrMissingArguments)) { return ReadinessStatus.READY; } - return new ReadinessStatus(false, ERROR_MESSAGE + String.join(", ", emptyOrMissingArguments)); + boolean propagationCtxIsEmpty = emptyOrMissingArguments.remove(PROPAGATION_CONFIG_ARGUMENT); + if (!propagationCtxIsEmpty) { + return new ReadinessStatus(false, MISSING_REQUIRED_ARGUMENTS_ERROR + String.join(", ", emptyOrMissingArguments)); + } + if (emptyOrMissingArguments.isEmpty()) { + return new ReadinessStatus(false, MISSING_PROPAGATION_TARGETS_ERROR); + } + return new ReadinessStatus(false, MISSING_PROPAGATION_TARGETS_AND_ARGUMENTS_ERROR + + String.join(", ", emptyOrMissingArguments)); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java index b3ea94e62c..a9e8eb5731 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java @@ -107,23 +107,26 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones(); List zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size()); argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> { + boolean firstEval = zoneState.getLastPresence() == null; GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates); zoneResults.add(eval); - if (createRelationsWithMatchedZones) { - GeofencingTransitionEvent transitionEvent = eval.transition(); - if (transitionEvent == null) { - return; - } - EntityRelation relation = switch (zoneGroupCfg.getDirection()) { - case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); - case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); - }; - ListenableFuture f = switch (transitionEvent) { - case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); - case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); - }; - relationFutures.add(f); + if (!createRelationsWithMatchedZones) { + return; } + GeofencingTransitionEvent transitionEvent = eval.transition(); + if (transitionEvent == null && !firstEval) { + return; + } + transitionEvent = transitionEvent == null ? GeofencingTransitionEvent.LEFT : transitionEvent; + EntityRelation relation = switch (zoneGroupCfg.getDirection()) { + case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); + case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); + }; + ListenableFuture f = switch (transitionEvent) { + case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); + case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); + }; + relationFutures.add(f); }); updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode); }); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index 24a0f0294a..b3ce7bbb44 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -221,7 +221,7 @@ public class GeofencingCalculatedFieldStateTest { ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, "allowedZones", geofencingAllowedZoneArgEntry, - "restrictedZones", new GeofencingArgumentEntry() + "restrictedZones", new GeofencingArgumentEntry(Collections.emptyMap()) ), ctx); assertThat(state.isReady()).isFalse(); assertThat(state.getReadinessStatus().errorMsg()).contains("restrictedZones"); @@ -290,10 +290,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } @Test @@ -360,10 +367,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } @Test @@ -432,10 +446,17 @@ public class GeofencingCalculatedFieldStateTest { assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); ArgumentCaptor deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); - verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); - EntityRelation leftRelation = deleteCaptor.getValue(); - assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); - assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); + verify(relationService, times(2)).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); + List deleteValues = deleteCaptor.getAllValues(); + assertThat(deleteValues).hasSize(2); + + EntityRelation deleteRelationFromFirstIteration = deleteValues.get(0); + assertThat(deleteRelationFromFirstIteration.getFrom()).isEqualTo(ZONE_2_ID); + assertThat(deleteRelationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); + + EntityRelation deleteRelationFromSecondIteration = deleteValues.get(1); + assertThat(deleteRelationFromSecondIteration.getFrom()).isEqualTo(ZONE_1_ID); + assertThat(deleteRelationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); } private CalculatedField getCalculatedField() { diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index 6ef945e4c6..e5bd6720e0 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -52,10 +54,12 @@ import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalcul import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutionException; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -126,21 +130,28 @@ public class PropagationCalculatedFieldStateTest { assertThat(state.isReady()).isFalse(); } - @Test - void testIsReadyWhenPropagationArgIsNull() { - initCtxAndState(false); - state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry), ctx); - assertThat(state.isReady()).isFalse(); - assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + private static Stream provideInvalidPropagationArgs() { + return Stream.of( + null, + new PropagationArgumentEntry(Collections.emptyList()) + ); } - @Test - void testIsReadyWhenPropagationArgIsEmpty() { + @ParameterizedTest + @MethodSource("provideInvalidPropagationArgs") + void testIsReadyWhenPropagationArgIsNullOrEmpty(ArgumentEntry propagationEntry) { initCtxAndState(false); - state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, - PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())), ctx); + + Map args = new HashMap<>(); + args.put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); // Valid user arg + + if (propagationEntry != null) { + args.put(PROPAGATION_CONFIG_ARGUMENT, propagationEntry); + } + state.update(args, ctx); assertThat(state.isReady()).isFalse(); - assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); + assertThat(state.getReadinessStatus().errorMsg()) + .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the relation type and direction configured."); } @Test diff --git a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java index 64b2fb032e..db24e51123 100644 --- a/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java @@ -107,7 +107,7 @@ class CalculatedFieldUtilsTest { assertThat(fromProto) .usingRecursiveComparison() - .ignoringFields("ctx", "requiredArguments", "readinessStatus") + .ignoringFields("ctx", "requiredArguments", "readinessStatus", "latestTimestamp") .isEqualTo(state); ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest");