From 9e416e7dee40865dae6650d3738f846bfa19a56e Mon Sep 17 00:00:00 2001 From: dshvaika Date: Mon, 24 Nov 2025 19:31:07 +0200 Subject: [PATCH 1/8] bugfixes for propagation and geofencing CFs --- .../ctx/state/BaseCalculatedFieldState.java | 5 ++ .../cf/ctx/state/CalculatedFieldState.java | 16 +++++- .../GeofencingCalculatedFieldState.java | 26 ++++++++-- .../geofencing/GeofencingEvalResult.java | 2 +- .../state/geofencing/GeofencingZoneState.java | 10 ++-- .../cf/CalculatedFieldIntegrationTest.java | 21 +++----- .../GeofencingCalculatedFieldStateTest.java | 52 +++++++++++++------ .../cf/ctx/state/GeofencingZoneStateTest.java | 22 ++++---- .../PropagationCalculatedFieldStateTest.java | 33 ++++++++---- .../server/msa/cf/CalculatedFieldTest.java | 7 ++- 10 files changed, 124 insertions(+), 70 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..6d1e4e99ef 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 @@ -29,6 +29,7 @@ import org.thingsboard.common.util.geo.Coordinates; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -112,7 +113,12 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { if (createRelationsWithMatchedZones) { GeofencingTransitionEvent transitionEvent = eval.transition(); if (transitionEvent == null) { - return; + if (!eval.firstEvaluation()) { + return; + } + transitionEvent = eval.status() == GeofencingPresenceStatus.INSIDE ? + GeofencingTransitionEvent.ENTERED : + GeofencingTransitionEvent.LEFT; } EntityRelation relation = switch (zoneGroupCfg.getDirection()) { case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); @@ -178,15 +184,27 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { private GeofencingEvalResult aggregateZoneGroup(List zoneResults) { boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); - boolean prevInside = zoneResults.stream() - .anyMatch(r -> GeofencingTransitionEvent.LEFT.equals(r.transition()) || r.transition() == null && r.status() == INSIDE); + + boolean firstEvaluation = zoneResults.stream().allMatch(GeofencingEvalResult::firstEvaluation); + if (firstEvaluation) { + return new GeofencingEvalResult(null, nowInside ? INSIDE : OUTSIDE, true); + } + + boolean prevInside = zoneResults.stream().anyMatch(r -> { + if (r.firstEvaluation()) { + return false; + } + return GeofencingTransitionEvent.LEFT.equals(r.transition()) + || (r.transition() == null && r.status() == INSIDE); + }); + GeofencingTransitionEvent transition = null; if (!prevInside && nowInside) { transition = GeofencingTransitionEvent.ENTERED; } else if (prevInside && !nowInside) { transition = GeofencingTransitionEvent.LEFT; } - return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE); + return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE, false); } private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java index c6bf3dd65e..edc3f01ae7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java @@ -20,5 +20,5 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.Geofencing import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; public record GeofencingEvalResult(@Nullable GeofencingTransitionEvent transition, - GeofencingPresenceStatus status) { + GeofencingPresenceStatus status, boolean firstEvaluation) { } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java index c849f5d169..8a49461d31 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java @@ -86,21 +86,17 @@ public class GeofencingZoneState { // first evaluation if (this.lastPresence == null) { this.lastPresence = status; - GeofencingTransitionEvent transition = null; - if (status == GeofencingPresenceStatus.INSIDE) { - transition = GeofencingTransitionEvent.ENTERED; - } - return new GeofencingEvalResult(transition, status); + return new GeofencingEvalResult(null, status, true); } // State changed if (this.lastPresence != status) { this.lastPresence = status; GeofencingTransitionEvent transition = (status == GeofencingPresenceStatus.INSIDE) ? GeofencingTransitionEvent.ENTERED : GeofencingTransitionEvent.LEFT; - return new GeofencingEvalResult(transition, status); + return new GeofencingEvalResult(transition, status, false); } // State unchanged - return new GeofencingEvalResult(null, status); + return new GeofencingEvalResult(null, status, false); } } diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index af0c1b9909..01dbf9ae2c 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -714,7 +714,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes doPost("/api/calculatedField", cf, CalculatedField.class); - // --- Assert initial evaluation (ENTERED / OUTSIDE) --- + // --- Assert initial evaluation (INSIDE / OUTSIDE) --- await().alias("initial geofencing evaluation") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -722,10 +722,9 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent"); // --- no restrictedZonesEvent as no transition happened yet - assertThat(attrs).isNotNull().isNotEmpty().hasSize(3); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE") + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") .containsEntry("restrictedZonesStatus", "OUTSIDE"); }); @@ -737,8 +736,6 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk()); // --- Assert no transition --- - // --- Assert attributes updated with the same values for restrictedZones --- - // --- Assert attributes updated with the new values for allowedZones --- await().alias("evaluation after version bump of geo argument") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -824,17 +821,16 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes doPost("/api/calculatedField", cf, CalculatedField.class); - // --- Assert initial evaluation (ENTERED / OUTSIDE) --- + // --- Assert initial evaluation (INSIDE / OUTSIDE) --- await().alias("initial geofencing evaluation") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus"); - assertThat(attrs).isNotNull().isNotEmpty().hasSize(3); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE") + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") .containsEntry("restrictedZonesStatus", "OUTSIDE"); }); @@ -935,10 +931,9 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus"); - assertThat(attrs).isNotNull().isNotEmpty().hasSize(2); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(1); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE"); + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE"); }); // --- Move device OUTSIDE Zone A (expect LEFT) --- 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..ecba5acc77 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"); @@ -249,7 +249,6 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result.getScope()).isEqualTo(output.getScope()); assertThat(result.getResult()).isEqualTo( JacksonUtil.newObjectNode() - .put("allowedZonesEvent", "ENTERED") .put("allowedZonesStatus", "INSIDE") .put("restrictedZonesStatus", "OUTSIDE") ); @@ -290,10 +289,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 @@ -322,9 +328,7 @@ public class GeofencingCalculatedFieldStateTest { assertThat(result).isNotNull(); assertThat(result.getType()).isEqualTo(output.getType()); assertThat(result.getScope()).isEqualTo(output.getScope()); - assertThat(result.getResult()).isEqualTo( - JacksonUtil.newObjectNode().put("allowedZonesEvent", "ENTERED") - ); + assertThat(result.getResult()).isEqualTo(JacksonUtil.newObjectNode()); SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); @@ -360,10 +364,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 +443,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/GeofencingZoneStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java index f6c6778ced..7c2cfa56b3 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java @@ -48,30 +48,30 @@ public class GeofencingZoneStateTest { void evaluate_initialInside_thenInsideAgain() { var inside = new Coordinates(50.4730, 30.5050); // first evaluation: no prior state -> ENTERED - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, true)); // same position again -> INSIDE (steady state) - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, false)); } @Test void evaluate_initialOutside_thenOutsideAgain() { var outside = new Coordinates(50.4760, 30.5110); // first evaluation: no prior state -> OUTSIDE - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, true)); // same position again -> OUTSIDE (steady state) - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, false)); } @Test void evaluate_inside_thenLeave() { var inside = new Coordinates(50.4730, 30.5050); var outside = new Coordinates(50.4760, 30.5110); - // enter - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + // inside + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, true)); // leave -> LEFT - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE, false)); // still outside -> OUTSIDE - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, false)); } @Test @@ -79,11 +79,11 @@ public class GeofencingZoneStateTest { var outside = new Coordinates(50.4760, 30.5110); var inside = new Coordinates(50.4730, 30.5050); // start outside - assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); + assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE, true)); // cross boundary -> ENTERED - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE, false)); // remain inside -> INSIDE - assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); + assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE, false)); } @Test 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/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 43d9a159fa..43fcbda5af 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -409,11 +409,10 @@ public class CalculatedFieldTest extends AbstractContainerTest { .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ArrayNode attrs = testRestClient.getAttributes(device.getId(), SERVER_SCOPE, - "allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus"); - assertThat(attrs).isNotNull().hasSize(3); + "allowedZonesEvent,allowedZonesStatus,restrictedZonesEvent,restrictedZonesStatus"); + assertThat(attrs).isNotNull().hasSize(2); Map m = kv(attrs); - assertThat(m).containsEntry("allowedZonesEvent", "ENTERED") - .containsEntry("allowedZonesStatus", "INSIDE") + assertThat(m).containsEntry("allowedZonesStatus", "INSIDE") .containsEntry("restrictedZonesStatus", "OUTSIDE"); }); From 19b438ffd97201d15fde5514d66e595907a60f8d Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 10:58:39 +0200 Subject: [PATCH 2/8] Fixed test due to logic changes --- .../org/thingsboard/server/utils/CalculatedFieldUtilsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"); From 95d297b55bcb5703223af2d8e3e5f4c2c6ef3a45 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 15:59:04 +0200 Subject: [PATCH 3/8] 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"); From 97a0e44f0a3181e4a6a19b9922a298cd3024dece Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 16:33:35 +0200 Subject: [PATCH 4/8] Added validation for missing perimeter attribute key --- .../service/cf/AbstractCalculatedFieldProcessingService.java | 4 ++-- .../service/cf/ctx/state/geofencing/GeofencingZoneState.java | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index d4d7fd8bf8..b8f1822bab 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -174,9 +174,9 @@ public abstract class AbstractCalculatedFieldProcessingService { return future.get(); } catch (ExecutionException e) { Throwable cause = e.getCause(); - throw new RuntimeException("Failed to fetch " + key + ": " + cause.getMessage(), cause); + throw new RuntimeException("Failed to fetch '" + key + "' argument: " + cause.getMessage(), cause); } catch (InterruptedException e) { - throw new RuntimeException("Failed to fetch" + key, e); + throw new RuntimeException("Failed to fetch '" + key + "' argument!", e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java index c849f5d169..ca4108570c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java @@ -46,10 +46,13 @@ public class GeofencingZoneState { public GeofencingZoneState(EntityId zoneId, KvEntry entry) { this.zoneId = zoneId; if (!(entry instanceof AttributeKvEntry attributeKvEntry)) { - throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName()); + throw new IllegalArgumentException("Invalid perimeter data source for zone with id: " + zoneId + ". Perimeter definition must be stored as attribute!"); } this.ts = attributeKvEntry.getLastUpdateTs(); this.version = attributeKvEntry.getVersion(); + if (entry.getValueAsString() == null) { + throw new IllegalArgumentException("Perimeter attribute key '" + entry.getKey() + "' not found for Zone with id: " + zoneId); + } this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class); } From e5519f3d8ea260e8949c37db4b562ea95a5373d5 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 16:54:46 +0200 Subject: [PATCH 5/8] fixed error message --- .../server/service/cf/ctx/state/CalculatedFieldState.java | 2 +- .../cf/ctx/state/PropagationCalculatedFieldStateTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 ec5681202f..998d7aa4a6 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 @@ -105,7 +105,7 @@ public interface CalculatedFieldState extends Closeable { 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."; + "Verify the configured relation type and direction."; 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); 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 e5bd6720e0..202b88b2eb 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 @@ -151,7 +151,7 @@ public class PropagationCalculatedFieldStateTest { state.update(args, ctx); assertThat(state.isReady()).isFalse(); assertThat(state.getReadinessStatus().errorMsg()) - .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the relation type and direction configured."); + .isEqualTo("No entities found via 'Propagation path to related entities'. Verify the configured relation type and direction."); } @Test From fa50bda8b7230325a0d604035618a7c0521bd021 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 17:23:40 +0200 Subject: [PATCH 6/8] Improvements to tenant profile upgrade script --- .../main/data/upgrade/basic/schema_update.sql | 69 ++++++------------- 1 file changed, 20 insertions(+), 49 deletions(-) diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index b8c49441fe..94b1a8b878 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -18,56 +18,27 @@ UPDATE tenant_profile SET profile_data = jsonb_set( - profile_data, - '{configuration}', - (profile_data -> 'configuration') - || jsonb_strip_nulls( - jsonb_build_object( - 'minAllowedScheduledUpdateIntervalInSecForCF', - CASE - WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' - THEN NULL - ELSE to_jsonb(60) - END, - 'maxRelationLevelPerCfArgument', - CASE - WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' - THEN NULL - ELSE to_jsonb(10) - END, - 'maxRelatedEntitiesToReturnPerCfArgument', - CASE - WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' - THEN NULL - ELSE to_jsonb(100) - END, - 'minAllowedDeduplicationIntervalInSecForCF', - CASE - WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' - THEN NULL - ELSE to_jsonb(60) - END, - 'minAllowedAggregationIntervalInSecForCF', - CASE - WHEN (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF' - THEN NULL - ELSE to_jsonb(60) - END - ) - ), - false - ) + profile_data, + '{configuration}', + jsonb_build_object( + 'minAllowedScheduledUpdateIntervalInSecForCF', 60, + 'maxRelationLevelPerCfArgument', 10, + 'maxRelatedEntitiesToReturnPerCfArgument', 100, + 'minAllowedDeduplicationIntervalInSecForCF', 60, + 'minAllowedAggregationIntervalInSecForCF', 60 + ) + || + jsonb_strip_nulls(profile_data -> 'configuration') +) WHERE NOT ( - (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF' - AND - (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument' - AND - (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' - AND - (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' - AND - (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF' - ); + jsonb_strip_nulls(profile_data -> 'configuration') ?& ARRAY[ + 'minAllowedScheduledUpdateIntervalInSecForCF', + 'maxRelationLevelPerCfArgument', + 'maxRelatedEntitiesToReturnPerCfArgument', + 'minAllowedDeduplicationIntervalInSecForCF', + 'minAllowedAggregationIntervalInSecForCF' + ] +); -- UPDATE TENANT PROFILE CONFIGURATION END From a23afd9bd496d0077dc6c4dadfed46f234f8b318 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Tue, 25 Nov 2025 18:08:20 +0200 Subject: [PATCH 7/8] Added positive validation for CFs relation parameters --- .../data/tenant/profile/DefaultTenantProfileConfiguration.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 7587f563ab..9a0bb1a1dc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -175,8 +176,10 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "60") private int minAllowedScheduledUpdateIntervalInSecForCF = 60; @Schema(example = "10") + @Positive private int maxRelationLevelPerCfArgument = 10; @Schema(example = "100") + @Positive private int maxRelatedEntitiesToReturnPerCfArgument = 100; @Builder.Default @Min(value = 1, message = "must be at least 1") From 9ca2b165526fe60ce565a671221c36b0077f59fe Mon Sep 17 00:00:00 2001 From: dshvaika Date: Wed, 26 Nov 2025 12:46:50 +0200 Subject: [PATCH 8/8] Set Builder.Default for Positive-only fields in Tenant profile --- .../tenant/profile/DefaultTenantProfileConfiguration.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 9a0bb1a1dc..87fa4a85da 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Positive; import lombok.AllArgsConstructor; import lombok.Builder; @@ -175,14 +174,16 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxArgumentsPerCF = 10; @Schema(example = "60") private int minAllowedScheduledUpdateIntervalInSecForCF = 60; + @Builder.Default @Schema(example = "10") @Positive private int maxRelationLevelPerCfArgument = 10; + @Builder.Default @Schema(example = "100") @Positive private int maxRelatedEntitiesToReturnPerCfArgument = 100; @Builder.Default - @Min(value = 1, message = "must be at least 1") + @Positive @Schema(example = "1000") private long maxDataPointsPerRollingArg = 1000; @Schema(example = "32")