Browse Source

Geofencing CF refactoring to new configuration init commit

feature/cfs
dshvaika 9 months ago
parent
commit
dd53892df2
  1. 8
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  2. 4
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  3. 18
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  4. 99
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java
  5. 100
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  6. 83
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
  7. 8
      application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java
  8. 35
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java
  9. 19
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
  10. 18
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  11. 24
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java
  12. 124
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java
  13. 5
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java
  14. 25
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduleSupportedCalculatedFieldConfiguration.java
  15. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java
  16. 63
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java
  17. 81
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java
  18. 368
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfigurationTest.java
  19. 17
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java
  20. 80
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java
  21. 21
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
  22. 4
      dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java
  23. 103
      dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java
  24. 83
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java

8
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java

@ -27,7 +27,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduleSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
@ -482,17 +482,17 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
private void scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(CalculatedFieldCtx cfCtx) {
CalculatedField cf = cfCtx.getCalculatedField();
if (!(cf.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration cfConfig)) {
if (!(cf.getConfiguration() instanceof ScheduleSupportedCalculatedFieldConfiguration scheduledCfConfig)) {
return;
}
if (!cfConfig.isScheduledUpdateEnabled()) {
if (!scheduledCfConfig.isScheduledUpdateEnabled()) {
return;
}
if (cfDynamicArgumentsRefreshTasks.containsKey(cf.getId())) {
log.debug("[{}][{}] Dynamic arguments refresh task for CF already exists!", tenantId, cf.getId());
return;
}
long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(cfConfig.getScheduledUpdateIntervalSec());
long refreshDynamicSourceInterval = TimeUnit.SECONDS.toMillis(scheduledCfConfig.getScheduledUpdateIntervalSec());
var scheduledMsg = new CalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfCtx.getCfId());
ScheduledFuture<?> scheduledFuture = systemContext

4
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java

@ -94,8 +94,8 @@ import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.SCOPE;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent

18
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java

@ -26,8 +26,10 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ExpressionBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.ScheduleSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -87,8 +89,9 @@ public class CalculatedFieldCtx {
this.mainEntityArguments = new HashMap<>();
this.linkedEntityArguments = new HashMap<>();
this.argNames = new ArrayList<>();
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) {
this.arguments.putAll(configuration.getArguments());
this.output = calculatedField.getConfiguration().getOutput();
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedConfig) {
this.arguments.putAll(argBasedConfig.getArguments());
for (Map.Entry<String, Argument> entry : arguments.entrySet()) {
var refId = entry.getValue().getRefEntityId();
var refKey = entry.getValue().getRefEntityKey();
@ -102,9 +105,10 @@ public class CalculatedFieldCtx {
}
}
this.argNames.addAll(arguments.keySet());
this.output = configuration.getOutput();
this.expression = configuration.getExpression();
this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) configuration).isUseLatestTs();
if (argBasedConfig instanceof ExpressionBasedCalculatedFieldConfiguration expressionBasedConfig) {
this.expression = expressionBasedConfig.getExpression();
this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) argBasedConfig).isUseLatestTs();
}
}
this.tbelInvokeService = tbelInvokeService;
this.relationService = relationService;
@ -319,8 +323,8 @@ public class CalculatedFieldCtx {
}
public boolean hasSchedulingConfigChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration otherConfig) {
if (calculatedField.getConfiguration() instanceof ScheduleSupportedCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof ScheduleSupportedCalculatedFieldConfiguration otherConfig) {
boolean refreshTriggerChanged = thisConfig.isScheduledUpdateEnabled() != otherConfig.isScheduledUpdateEnabled();
boolean refreshIntervalChanged = thisConfig.getScheduledUpdateIntervalSec() != otherConfig.getScheduledUpdateIntervalSec();
return refreshTriggerChanged || refreshIntervalChanged;

99
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy;
import org.thingsboard.server.common.data.cf.configuration.GeofencingTransitionEvent;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
@ -35,10 +36,11 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingPresenceStatus.INSIDE;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingPresenceStatus.OUTSIDE;
@ -115,45 +117,54 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
Coordinates entityCoordinates = new Coordinates(latitude, longitude);
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
if (configuration.isCreateRelationsWithMatchedZones()) {
return calculateWithRelations(entityId, ctx, entityCoordinates, configuration);
}
return calculateWithoutRelations(ctx, entityCoordinates, configuration);
// TODO: refactor
return calculate(entityId, ctx, entityCoordinates, configuration);
}
private ListenableFuture<CalculatedFieldResult> calculateWithRelations(
private ListenableFuture<CalculatedFieldResult> calculate(
EntityId entityId,
CalculatedFieldCtx ctx,
Coordinates entityCoordinates,
GeofencingCalculatedFieldConfiguration configuration) {
var zoneGroupReportStrategies = configuration.getZoneGroupReportStrategies();
Map<String, ZoneGroupConfiguration> zoneGroups = configuration
.getZoneGroups()
.stream()
.collect(Collectors.toMap(ZoneGroupConfiguration::getName, Function.identity()));
ObjectNode resultNode = JacksonUtil.newObjectNode();
List<ListenableFuture<Boolean>> relationFutures = new ArrayList<>();
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
GeofencingReportStrategy geofencingReportStrategy = zoneGroupReportStrategies.get(argumentKey);
List<GeofencingEvalResult> zoneResults = new ArrayList<>();
argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> {
GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates);
zoneResults.add(eval);
GeofencingTransitionEvent transitionEvent = eval.transition();
if (transitionEvent == null) {
return;
}
EntityRelation relation = toRelation(zoneId, entityId, configuration);
ListenableFuture<Boolean> f = switch (transitionEvent) {
case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation);
case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation);
};
relationFutures.add(f);
});
updateResultNode(argumentKey, zoneResults, geofencingReportStrategy, resultNode);
ZoneGroupConfiguration zoneGroupConfiguration = zoneGroups.get(argumentKey);
if (zoneGroupConfiguration.isCreateRelationsWithMatchedZones()) {
List<GeofencingEvalResult> zoneResults = new ArrayList<>();
argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> {
GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates);
zoneResults.add(eval);
GeofencingTransitionEvent transitionEvent = eval.transition();
if (transitionEvent == null) {
return;
}
EntityRelation relation = toRelation(zoneId, entityId, zoneGroupConfiguration);
ListenableFuture<Boolean> f = switch (transitionEvent) {
case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation);
case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation);
};
relationFutures.add(f);
});
updateResultNode(argumentKey, zoneResults, zoneGroupConfiguration.getReportStrategy(), resultNode);
} else {
List<GeofencingEvalResult> zoneResults = argumentEntry.getZoneStates().values().stream()
.map(zs -> zs.evaluate(entityCoordinates))
.toList();
updateResultNode(argumentKey, zoneResults, zoneGroupConfiguration.getReportStrategy(), resultNode);
}
});
var result = calculationResult(ctx, resultNode);
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode);
if (relationFutures.isEmpty()) {
return Futures.immediateFuture(result);
}
@ -162,30 +173,6 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
MoreExecutors.directExecutor());
}
private ListenableFuture<CalculatedFieldResult> calculateWithoutRelations(
CalculatedFieldCtx ctx,
Coordinates entityCoordinates,
GeofencingCalculatedFieldConfiguration configuration) {
var zoneGroupReportStrategies = configuration.getZoneGroupReportStrategies();
ObjectNode resultNode = JacksonUtil.newObjectNode();
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
var geofencingReportStrategy = zoneGroupReportStrategies.get(argumentKey);
List<GeofencingEvalResult> zoneResults = argumentEntry.getZoneStates().values().stream()
.map(zs -> zs.evaluate(entityCoordinates))
.toList();
updateResultNode(argumentKey, zoneResults, geofencingReportStrategy, resultNode);
});
return Futures.immediateFuture(calculationResult(ctx, resultNode));
}
private CalculatedFieldResult calculationResult(CalculatedFieldCtx ctx, ObjectNode resultNode) {
return new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode);
}
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() {
return arguments.entrySet()
.stream()
@ -226,10 +213,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE);
}
private EntityRelation toRelation(EntityId zoneId, EntityId entityId, GeofencingCalculatedFieldConfiguration configuration) {
return switch (configuration.getZoneRelationDirection()) {
case TO -> new EntityRelation(zoneId, entityId, configuration.getZoneRelationType());
case FROM -> new EntityRelation(entityId, zoneId, configuration.getZoneRelationType());
private EntityRelation toRelation(EntityId zoneId, EntityId entityId, ZoneGroupConfiguration configuration) {
return switch (configuration.getDirection()) {
case TO -> new EntityRelation(zoneId, entityId, configuration.getRelationType());
case FROM -> new EntityRelation(entityId, zoneId, configuration.getRelationType());
};
}

100
application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java

@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
@ -39,6 +40,8 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.EntityId;
@ -48,6 +51,7 @@ import org.thingsboard.server.controller.CalculatedFieldControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -55,6 +59,8 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
@DaoSqlTest
public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest {
@ -131,7 +137,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("86.0");
});
Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T");
Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T");
savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
@ -143,7 +149,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("104.0");
});
savedCalculatedField.getConfiguration().setExpression("1.8 * T + 32");
((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("1.8 * T + 32");
savedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS)
@ -664,41 +670,27 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST on the device
Argument lat = new Argument();
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
Argument lon = new Argument();
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone groups: ATTRIBUTE on specific assets (one zone per group)
Argument allowedZones = new Argument();
var allowedZonesRefDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZonesRefDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedZonesRefDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZonesRefDynamicSourceConfiguration.setMaxLevel(1);
allowedZonesRefDynamicSourceConfiguration.setFetchLastLevelOnly(true);
allowedZones.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
allowedZones.setRefDynamicSourceConfiguration(allowedZonesRefDynamicSourceConfiguration);
Argument restrictedZones = new Argument();
var restrictedZonesRefDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedZonesRefDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
restrictedZonesRefDynamicSourceConfiguration.setRelationType("RestrictedZone");
restrictedZonesRefDynamicSourceConfiguration.setMaxLevel(1);
restrictedZonesRefDynamicSourceConfiguration.setFetchLastLevelOnly(true);
restrictedZones.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
restrictedZones.setRefDynamicSourceConfiguration(restrictedZonesRefDynamicSourceConfiguration);
cfg.setArguments(Map.of(
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
"allowedZones", allowedZones,
"restrictedZones", restrictedZones
));
cfg.setZoneGroupReportStrategies(Map.of(
"allowedZones", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS,
"restrictedZones", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS
));
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("allowedZones", "zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneDynamicSourceConfiguration.setMaxLevel(1);
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("restrictedZones", "zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone");
restrictedZoneDynamicSourceConfiguration.setMaxLevel(1);
restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
cfg.setZoneGroups(List.of(allowedZonesGroup, restrictedZonesGroup));
// Output to server attributes
Output out = new Output();
@ -789,31 +781,16 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
cf.setDebugSettings(DebugSettings.off());
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
// Coordinates (TS_LATEST)
Argument lat = new Argument();
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
Argument lon = new Argument();
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
// Dynamic group 'allowedZones' resolved by relations (FROM device -> assets of type AllowedZone)
Argument allowedZones = new Argument();
var dyn = new RelationQueryDynamicSourceConfiguration();
dyn.setDirection(EntitySearchDirection.FROM);
dyn.setRelationType("AllowedZone");
dyn.setMaxLevel(1);
dyn.setFetchLastLevelOnly(true);
allowedZones.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
allowedZones.setRefDynamicSourceConfiguration(dyn);
cfg.setArguments(Map.of(
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
"allowedZones", allowedZones
));
// Report all for the group
cfg.setZoneGroupReportStrategies(Map.of("allowedZones", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS));
var allowedZonesGroup = new ZoneGroupConfiguration("allowedZones", "zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneDynamicSourceConfiguration.setMaxLevel(1);
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
cfg.setZoneGroups(List.of(allowedZonesGroup));
// Server attributes output
Output out = new Output();
@ -827,7 +804,10 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
cf.setConfiguration(cfg);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class);
assertThat(savedCalculatedField).isNotNull();
assertThat(savedCalculatedField.getConfiguration().isScheduledUpdateEnabled()).isTrue();
CalculatedFieldConfiguration configuration = savedCalculatedField.getConfiguration();
assertThat(configuration).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
var geofencingConfiguration = (GeofencingCalculatedFieldConfiguration) configuration;
assertThat(geofencingConfiguration.isScheduledUpdateEnabled()).isTrue();
// --- Assert initial evaluation (ENTERED) ---
await().alias("initial geofencing evaluation")

83
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java

@ -25,15 +25,14 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
@ -59,9 +58,9 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
@ExtendWith(MockitoExtension.class)
public class GeofencingCalculatedFieldStateTest {
@ -273,12 +272,12 @@ public class GeofencingCalculatedFieldStateTest {
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(relationFromFirstIteration.getType()).isEqualTo(configuration.getZoneRelationType());
assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone");
EntityRelation relationFromSecondIteration = saveValues.get(1);
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
assertThat(relationFromSecondIteration.getType()).isEqualTo(configuration.getZoneRelationType());
assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone");
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
@ -343,12 +342,12 @@ public class GeofencingCalculatedFieldStateTest {
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(relationFromFirstIteration.getType()).isEqualTo(configuration.getZoneRelationType());
assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone");
EntityRelation relationFromSecondIteration = saveValues.get(1);
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
assertThat(relationFromSecondIteration.getType()).isEqualTo(configuration.getZoneRelationType());
assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone");
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
@ -415,12 +414,12 @@ public class GeofencingCalculatedFieldStateTest {
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(relationFromFirstIteration.getType()).isEqualTo(configuration.getZoneRelationType());
assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone");
EntityRelation relationFromSecondIteration = saveValues.get(1);
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID);
assertThat(relationFromSecondIteration.getType()).isEqualTo(configuration.getZoneRelationType());
assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone");
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
@ -448,43 +447,31 @@ public class GeofencingCalculatedFieldStateTest {
private CalculatedFieldConfiguration getCalculatedFieldConfig(GeofencingReportStrategy reportStrategy) {
var config = new GeofencingCalculatedFieldConfiguration();
Argument argument1 = new Argument();
argument1.setRefEntityId(DEVICE_ID);
var refEntityKey1 = new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null);
argument1.setRefEntityKey(refEntityKey1);
Argument argument2 = new Argument();
argument2.setRefEntityId(DEVICE_ID);
var refEntityKey2 = new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null);
argument2.setRefEntityKey(refEntityKey2);
Argument argument3 = new Argument();
var refEntityKey3 = new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, null);
var refDynamicSourceConfiguration3 = new RelationQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration3.setDirection(EntitySearchDirection.TO);
refDynamicSourceConfiguration3.setRelationType("AllowedZone");
refDynamicSourceConfiguration3.setMaxLevel(1);
refDynamicSourceConfiguration3.setFetchLastLevelOnly(true);
argument3.setRefEntityKey(refEntityKey3);
argument3.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration3);
Argument argument4 = new Argument();
var refEntityKey4 = new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, null);
var refDynamicSourceConfiguration4 = new RelationQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration4.setDirection(EntitySearchDirection.TO);
refDynamicSourceConfiguration4.setRelationType("RestrictedZone");
refDynamicSourceConfiguration4.setMaxLevel(1);
refDynamicSourceConfiguration4.setFetchLastLevelOnly(true);
argument4.setRefEntityKey(refEntityKey4);
argument4.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration4);
config.setArguments(Map.of("latitude", argument1, "longitude", argument2, "allowedZones", argument3, "restrictedZones", argument4));
config.setZoneGroupReportStrategies(Map.of("allowedZones", reportStrategy, "restrictedZones", reportStrategy));
config.setCreateRelationsWithMatchedZones(true);
config.setZoneRelationType("CurrentZone");
config.setZoneRelationDirection(EntitySearchDirection.TO);
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
entityCoordinates.setRefEntityId(DEVICE_ID);
config.setEntityCoordinates(entityCoordinates);
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("allowedZones", "zone", reportStrategy, true);
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO);
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneDynamicSourceConfiguration.setMaxLevel(1);
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration);
allowedZonesGroup.setRelationType("CurrentZone");
allowedZonesGroup.setDirection(EntitySearchDirection.TO);
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("restrictedZones", "zone", reportStrategy, true);
var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO);
restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone");
restrictedZoneDynamicSourceConfiguration.setMaxLevel(1);
restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true);
restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration);
restrictedZonesGroup.setRelationType("CurrentZone");
restrictedZonesGroup.setDirection(EntitySearchDirection.TO);
config.setZoneGroups(List.of(allowedZonesGroup, restrictedZonesGroup));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);

8
application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java

@ -638,7 +638,9 @@ public class VersionControlTest extends AbstractControllerTest {
assertThat(importedField.getName()).isEqualTo(deviceCalculatedField.getName());
assertThat(importedField.getType()).isEqualTo(deviceCalculatedField.getType());
assertThat(importedField.getId()).isNotEqualTo(deviceCalculatedField.getId());
assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId());
assertThat(importedField.getConfiguration()).isInstanceOf(SimpleCalculatedFieldConfiguration.class);
SimpleCalculatedFieldConfiguration simpleCfg = (SimpleCalculatedFieldConfiguration) importedField.getConfiguration();
assertThat(simpleCfg.getArguments().get("T").getRefEntityId()).isEqualTo(importedAsset.getId());
});
List<CalculatedField> importedAssetCalculatedFields = findCalculatedFieldsByEntityId(importedAsset.getId());
@ -647,7 +649,9 @@ public class VersionControlTest extends AbstractControllerTest {
assertThat(importedField.getName()).isEqualTo(assetCalculatedField.getName());
assertThat(importedField.getType()).isEqualTo(assetCalculatedField.getType());
assertThat(importedField.getId()).isNotEqualTo(assetCalculatedField.getId());
assertThat(importedField.getConfiguration().getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId());
assertThat(importedField.getConfiguration()).isInstanceOf(SimpleCalculatedFieldConfiguration.class);
SimpleCalculatedFieldConfiguration simpleCfg = (SimpleCalculatedFieldConfiguration) importedField.getConfiguration();
assertThat(simpleCfg.getArguments().get("T").getRefEntityId()).isEqualTo(importedDevice.getId());
});
}

35
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java

@ -1,29 +1,24 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.Map;
public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
Map<String, Argument> getArguments();
String getExpression();
void setExpression(String expression);
Output getOutput();
@JsonIgnore
default boolean isScheduledUpdateEnabled() {
return false;
}
default void setScheduledUpdateIntervalSec(int scheduledUpdateIntervalSec) {
}
default int getScheduledUpdateIntervalSec() {
return 0;
}
}

19
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java

@ -27,7 +27,7 @@ import java.util.Objects;
import java.util.stream.Collectors;
@Data
public abstract class BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration {
protected Map<String, Argument> arguments;
protected String expression;
@ -41,23 +41,6 @@ public abstract class BaseCalculatedFieldConfiguration implements ArgumentsBased
.collect(Collectors.toList());
}
@Override
public List<CalculatedFieldLink> buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) {
return getReferencedEntities().stream()
.filter(referencedEntity -> !referencedEntity.equals(cfEntityId))
.map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId))
.collect(Collectors.toList());
}
@Override
public CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) {
CalculatedFieldLink link = new CalculatedFieldLink();
link.setTenantId(tenantId);
link.setEntityId(referencedEntityId);
link.setCalculatedFieldId(calculatedFieldId);
return link;
}
@Override
public void validate() {
if (arguments.containsKey("ctx")) {

18
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java

@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.TenantId;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@ -44,6 +45,8 @@ public interface CalculatedFieldConfiguration {
@JsonIgnore
CalculatedFieldType getType();
Output getOutput();
void validate();
@JsonIgnore
@ -51,8 +54,19 @@ public interface CalculatedFieldConfiguration {
return Collections.emptyList();
}
CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId);
default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) {
CalculatedFieldLink link = new CalculatedFieldLink();
link.setTenantId(tenantId);
link.setEntityId(referencedEntityId);
link.setCalculatedFieldId(calculatedFieldId);
return link;
}
List<CalculatedFieldLink> buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId);
default List<CalculatedFieldLink> buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, CalculatedFieldId calculatedFieldId) {
return getReferencedEntities().stream()
.filter(referencedEntity -> !referencedEntity.equals(cfEntityId))
.map(referencedEntityId -> buildCalculatedFieldLink(tenantId, referencedEntityId, calculatedFieldId))
.collect(Collectors.toList());
}
}

24
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration;
public interface ExpressionBasedCalculatedFieldConfiguration extends ArgumentsBasedCalculatedFieldConfiguration {
String getExpression();
void setExpression(String expression);
}

124
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java

@ -15,34 +15,26 @@
*/
package org.thingsboard.server.common.data.cf.configuration;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Data
@EqualsAndHashCode(callSuper = true)
public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
private static final Set<String> coordinateKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY
);
public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduleSupportedCalculatedFieldConfiguration {
private EntityCoordinates entityCoordinates;
private List<ZoneGroupConfiguration> zoneGroups;
private int scheduledUpdateIntervalSec;
private boolean createRelationsWithMatchedZones;
private String zoneRelationType;
private EntitySearchDirection zoneRelationDirection;
private Map<String, GeofencingReportStrategy> zoneGroupReportStrategies;
private Output output;
@Override
public CalculatedFieldType getType() {
@ -50,91 +42,39 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
}
@Override
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalSec > 0 && arguments.values().stream().anyMatch(Argument::hasDynamicSource);
@JsonIgnore
public Map<String, Argument> getArguments() {
Map<String, Argument> args = new HashMap<>(entityCoordinates.toArguments());
zoneGroups.forEach(zg -> args.put(zg.getName(), zg.toArgument()));
return args;
}
@Override
public void validate() {
if (arguments == null) {
throw new IllegalArgumentException("Geofencing calculated field arguments must be specified!");
}
validateCoordinateArguments();
Map<String, Argument> zoneGroupsArguments = getZoneGroupArguments();
if (zoneGroupsArguments.isEmpty()) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
validateZoneGroupAruguments(zoneGroupsArguments);
validateZoneGroupConfigurations(zoneGroupsArguments);
validateZoneRelationsConfiguration();
public Output getOutput() {
return output;
}
private void validateZoneRelationsConfiguration() {
if (!createRelationsWithMatchedZones) {
return;
}
if (StringUtils.isBlank(zoneRelationType)) {
throw new IllegalArgumentException("Zone relation type must be specified to create relations with matched zones!");
}
if (zoneRelationDirection == null) {
throw new IllegalArgumentException("Zone relation direction must be specified to create relations with matched zones!");
}
@Override
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalSec > 0 && zoneGroups.stream().anyMatch(ZoneGroupConfiguration::hasDynamicSource);
}
private void validateZoneGroupConfigurations(Map<String, Argument> zoneGroupsArguments) {
if (zoneGroupReportStrategies == null || zoneGroupReportStrategies.isEmpty()) {
throw new IllegalArgumentException("Zone groups reporting strategies should be specified!");
@Override
public void validate() {
if (entityCoordinates == null) {
throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!");
}
zoneGroupsArguments.forEach((zoneGroupName, zoneGroupArgument) -> {
GeofencingReportStrategy geofencingReportStrategy = zoneGroupReportStrategies.get(zoneGroupName);
if (geofencingReportStrategy == null) {
throw new IllegalArgumentException("Zone group report strategy is not configured for '" + zoneGroupName + "' argument!");
}
});
}
private void validateCoordinateArguments() {
for (String coordinateKey : coordinateKeys) {
Argument argument = arguments.get(coordinateKey);
if (argument == null) {
throw new IllegalArgumentException("Missing required coordinates argument: " + coordinateKey + "!");
}
ReferencedEntityKey refEntityKey = validateAndGetRefEntityKey(argument, coordinateKey);
if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument '" + coordinateKey + "' must be of type TS_LATEST!");
}
if (argument.hasDynamicSource()) {
throw new IllegalArgumentException("Dynamic source is not allowed for '" + coordinateKey + "' argument!");
}
if (zoneGroups == null || zoneGroups.isEmpty()) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
}
private void validateZoneGroupAruguments(Map<String, Argument> zoneGroupsArguments) {
zoneGroupsArguments.forEach((argumentKey, argument) -> {
ReferencedEntityKey refEntityKey = validateAndGetRefEntityKey(argument, argumentKey);
if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type ATTRIBUTE!");
entityCoordinates.validate();
Set<String> seen = new HashSet<>();
for (var zg : zoneGroups) {
if (!seen.add(zg.getName())) {
throw new IllegalArgumentException("Geofencing calculated field zone group name must be unique!");
}
if (argument.hasDynamicSource()) {
argument.getRefDynamicSourceConfiguration().validate();
}
});
}
private Map<String, Argument> getZoneGroupArguments() {
return arguments.entrySet()
.stream()
.filter(entry -> entry.getValue() != null)
.filter(entry -> !coordinateKeys.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
private ReferencedEntityKey validateAndGetRefEntityKey(Argument argument, String argumentKey) {
ReferencedEntityKey refEntityKey = argument.getRefEntityKey();
if (refEntityKey == null || refEntityKey.getType() == null) {
throw new IllegalArgumentException("Missing or invalid reference entity key for argument: " + argumentKey);
zg.validate();
}
return refEntityKey;
}
}

5
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java

@ -37,7 +37,6 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
private boolean fetchLastLevelOnly;
private EntitySearchDirection direction;
private String relationType;
private List<EntityType> entityTypes;
@Override
public CFArgumentDynamicSourceType getType() {
@ -62,7 +61,7 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
@JsonIgnore
public boolean isSimpleRelation() {
return maxLevel == 1 && CollectionsUtil.isEmpty(entityTypes);
return maxLevel == 1;
}
public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) {
@ -71,7 +70,7 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
}
var entityRelationsQuery = new EntityRelationsQuery();
entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly));
entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, entityTypes)));
entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, Collections.emptyList())));
return entityRelationsQuery;
}

25
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduleSupportedCalculatedFieldConfiguration.java

@ -0,0 +1,25 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration;
public interface ScheduleSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
boolean isScheduledUpdateEnabled();
int getScheduledUpdateIntervalSec();
void setScheduledUpdateIntervalSec(int interval);
}

2
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java

@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@Data
@EqualsAndHashCode(callSuper = true)
public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration {
private boolean useLatestTs;

63
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java

@ -0,0 +1,63 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import lombok.Data;
import org.springframework.lang.Nullable;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.Map;
@Data
public class EntityCoordinates {
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
private final String latitudeKeyName;
private final String longitudeKeyName;
@Nullable
private EntityId refEntityId;
public void validate() {
if (StringUtils.isBlank(latitudeKeyName)) {
throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!");
}
if (StringUtils.isBlank(longitudeKeyName)) {
throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!");
}
}
public Map<String, Argument> toArguments() {
return Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName),
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument(longitudeKeyName)
);
}
private Argument toArgument(String keyName) {
var argument = new Argument();
argument.setRefEntityId(refEntityId);
argument.setRefEntityKey(new ReferencedEntityKey(keyName, ArgumentType.TS_LATEST, null));
return argument;
}
}

81
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import lombok.Data;
import org.springframework.lang.Nullable;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CfArgumentDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@Data
public class ZoneGroupConfiguration {
@Nullable
private EntityId refEntityId;
private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
private final String name;
private final String perimeterKeyName;
private final GeofencingReportStrategy reportStrategy;
private final boolean createRelationsWithMatchedZones;
private String relationType;
private EntitySearchDirection direction;
public void validate() {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("Zone group name must be specified!");
}
if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) {
throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!");
}
if (StringUtils.isBlank(perimeterKeyName)) {
throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!");
}
if (reportStrategy == null) {
throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!");
}
if (!createRelationsWithMatchedZones) {
return;
}
if (StringUtils.isBlank(relationType)) {
throw new IllegalArgumentException("Relation type must be specified for '" + name + "' zone group!");
}
if (direction == null) {
throw new IllegalArgumentException("Relation direction must be specified for '" + name + "' zone group!");
}
}
public boolean hasDynamicSource() {
return refDynamicSourceConfiguration != null;
}
public Argument toArgument() {
var argument = new Argument();
argument.setRefEntityId(refEntityId);
argument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
argument.setRefEntityKey(new ReferencedEntityKey(perimeterKeyName, ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
return argument;
}
}

368
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfigurationTest.java

@ -17,26 +17,24 @@ package org.thingsboard.server.common.data.cf.configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
@ExtendWith(MockitoExtension.class)
public class GeofencingCalculatedFieldConfigurationTest {
@ -48,134 +46,20 @@ public class GeofencingCalculatedFieldConfigurationTest {
}
@Test
void validateShouldThrowWhenArgumentsNull() {
void validateShouldThrowWhenEntityCoordinatesNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setArguments(null);
cfg.setEntityCoordinates(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field arguments must be specified!");
}
@ParameterizedTest
@MethodSource("missingCoordinateArgs")
void validateShouldThrowWhenCoordinateArgIsMissing(String missingKey, String presentKey) {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(missingKey, null);
arguments.put(presentKey, toArgument(presentKey, ArgumentType.TS_LATEST));
cfg.setArguments(arguments);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Missing required coordinates argument: " + missingKey + "!");
}
private static Stream<Arguments> missingCoordinateArgs() {
return Stream.of(
Arguments.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY),
Arguments.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ENTITY_ID_LATITUDE_ARGUMENT_KEY)
);
}
@ParameterizedTest
@MethodSource("nullRefKeyCoordinateArgs")
void validateShouldThrowWhenReferenceKeyIsNullOrTypeNull(
String brokenKey, Argument brokenArg, String okKey) {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setArguments(Map.of(
brokenKey, brokenArg,
okKey, toArgument(okKey, ArgumentType.TS_LATEST)
));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Missing or invalid reference entity key for argument: " + brokenKey);
}
private static Stream<Arguments> nullRefKeyCoordinateArgs() {
return Stream.of(
// null ref key on latitude
Arguments.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
toArgument(null),
ENTITY_ID_LONGITUDE_ARGUMENT_KEY
),
// null ref key on longitude
Arguments.of(
ENTITY_ID_LONGITUDE_ARGUMENT_KEY,
toArgument(null),
ENTITY_ID_LATITUDE_ARGUMENT_KEY
),
// null type on latitude
Arguments.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
toArgument(new ReferencedEntityKey("latitude", null, null)),
ENTITY_ID_LONGITUDE_ARGUMENT_KEY
),
// null type on longitude
Arguments.of(
ENTITY_ID_LONGITUDE_ARGUMENT_KEY,
toArgument(new ReferencedEntityKey("longitude", null, null)),
ENTITY_ID_LATITUDE_ARGUMENT_KEY
)
);
}
@ParameterizedTest
@MethodSource("wrongTypeCoordinateArgs")
void validateShouldThrowWhenCoordinateHasWrongType(String wrongKey, String okKey) {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setArguments(Map.of(
wrongKey, toArgument(wrongKey, ArgumentType.ATTRIBUTE),
okKey, toArgument(okKey, ArgumentType.TS_LATEST)
));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument '" + wrongKey + "' must be of type TS_LATEST!");
}
private static Stream<Arguments> wrongTypeCoordinateArgs() {
return Stream.of(
Arguments.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY),
Arguments.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ENTITY_ID_LATITUDE_ARGUMENT_KEY)
);
}
@ParameterizedTest
@MethodSource("dynamicCoordinateArgs")
void validateShouldThrowWhenCoordinateHasDynamicSource(String dynamicKey, String okKey) {
var cfg = new GeofencingCalculatedFieldConfiguration();
var dynamicArg = toArgument(dynamicKey, ArgumentType.TS_LATEST);
dynamicArg.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
cfg.setArguments(Map.of(
dynamicKey, dynamicArg,
okKey, toArgument(okKey, ArgumentType.TS_LATEST)
));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Dynamic source is not allowed for '" + dynamicKey + "' argument!");
}
private static Stream<Arguments> dynamicCoordinateArgs() {
return Stream.of(
Arguments.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY),
Arguments.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ENTITY_ID_LATITUDE_ARGUMENT_KEY)
);
.hasMessage("Geofencing calculated field entity coordinates must be specified!");
}
@Test
void validateShouldThrowWhenGeofencingArgumentsMissing() {
void validateShouldThrowWhenZoneGroupsNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setArguments(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST),
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST)
));
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
cfg.setZoneGroups(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
@ -183,149 +67,59 @@ public class GeofencingCalculatedFieldConfigurationTest {
}
@Test
void validateShouldThrowWhenZoneGroupArgumentIsNull() {
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
arguments.put("someZones", null);
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
cfg.setZoneGroups(List.of(zoneGroupConfiguration));
cfg.setArguments(arguments);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
@Test
void validateShouldThrowWhenZoneGroupArgumentHasInvalidArgumentType() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
arguments.put("allowedZones", toArgument("allowedZone", ArgumentType.TS_LATEST));
cfg.setArguments(arguments);
cfg.validate();
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument 'allowedZones' must be of type ATTRIBUTE!");
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfiguration).validate();
}
@Test
void validateShouldCallDynamicSourceConfigValidationWhenZoneGroupArgumentHasDynamicSourceConfiguration() {
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE);
var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class);
allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock);
arguments.put("allowedZones", allowedZonesArg);
Map<String, GeofencingReportStrategy> zoneGroupReportStrategies =
Map.of("allowedZones", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS);
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
var zoneGroupConfigurationA = mock(ZoneGroupConfiguration.class);
var zoneGroupConfigurationB = mock(ZoneGroupConfiguration.class);
cfg.setArguments(arguments);
cfg.setZoneGroupReportStrategies(zoneGroupReportStrategies);
cfg.validate();
when(zoneGroupConfigurationA.getName()).thenReturn("zoneGroupA");
when(zoneGroupConfigurationB.getName()).thenReturn("zoneGroupB");
verify(refDynamicSourceConfigurationMock).validate();
}
cfg.setZoneGroups(List.of(zoneGroupConfigurationA, zoneGroupConfigurationB));
@Test
void validateShouldThrowWhenZoneGroupConfigurationIsMissing() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE);
var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class);
allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock);
arguments.put("allowedZones", allowedZonesArg);
cfg.setArguments(arguments);
cfg.setZoneGroupReportStrategies(null);
cfg.setCreateRelationsWithMatchedZones(false);
assertThatCode(cfg::validate).doesNotThrowAnyException();
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Zone groups reporting strategies should be specified!");
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfigurationA).validate();
verify(zoneGroupConfigurationB).validate();
}
@Test
void validateShouldThrowWhenZoneGroupArgumentReportStrategyIsMissing() {
void validateShouldThrowWhenZoneGroupNamesDuplicated() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE);
var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class);
allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock);
arguments.put("allowedZones", allowedZonesArg);
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
var zoneGroupConfigurationA = mock(ZoneGroupConfiguration.class);
var zoneGroupConfigurationB = mock(ZoneGroupConfiguration.class);
Map<String, GeofencingReportStrategy> zoneGroupReportStrategies =
Map.of("someOtherZones", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS);
when(zoneGroupConfigurationA.getName()).thenReturn("zoneGroupDuplicated");
when(zoneGroupConfigurationB.getName()).thenReturn("zoneGroupDuplicated");
cfg.setArguments(arguments);
cfg.setZoneGroupReportStrategies(zoneGroupReportStrategies);
cfg.setCreateRelationsWithMatchedZones(false);
cfg.setZoneGroups(List.of(zoneGroupConfigurationA, zoneGroupConfigurationB));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Zone group report strategy is not configured for 'allowedZones' argument!");
}
.hasMessage("Geofencing calculated field zone group name must be unique!");
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = " ")
void validateShouldThrowWhenHasBlankOrNullZoneRelationType(String zoneRelationType) {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE);
var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class);
allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock);
arguments.put("allowedZones", allowedZonesArg);
Map<String, GeofencingReportStrategy> zoneGroupReportStrategies =
Map.of("allowedZones", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS);
cfg.setArguments(arguments);
cfg.setZoneGroupReportStrategies(zoneGroupReportStrategies);
cfg.setCreateRelationsWithMatchedZones(true);
cfg.setZoneRelationType(zoneRelationType);
cfg.setZoneRelationDirection(EntitySearchDirection.TO);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Zone relation type must be specified to create relations with matched zones!");
}
@Test
void validateShouldThrowWhenNoZoneRelationDirectionSpecified() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var arguments = new HashMap<String, Argument>();
arguments.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
arguments.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
Argument allowedZonesArg = toArgument("allowedZone", ArgumentType.ATTRIBUTE);
var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class);
allowedZonesArg.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock);
arguments.put("allowedZones", allowedZonesArg);
Map<String, GeofencingReportStrategy> zoneGroupReportStrategies =
Map.of("allowedZones", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS);
cfg.setArguments(arguments);
cfg.setZoneGroupReportStrategies(zoneGroupReportStrategies);
cfg.setCreateRelationsWithMatchedZones(true);
cfg.setZoneRelationType("SomeRelationType");
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Zone relation direction must be specified to create relations with matched zones!");
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfigurationA).validate();
verify(zoneGroupConfigurationB, never()).validate();
}
@Test
@ -336,17 +130,11 @@ public class GeofencingCalculatedFieldConfigurationTest {
}
@Test
void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButArgumentsAreEmpty() {
void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButNoZonesWithDynamicArguments() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setArguments(Map.of());
cfg.setScheduledUpdateIntervalSec(60);
assertThat(cfg.isScheduledUpdateEnabled()).isFalse();
}
@Test
void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButDynamicArgumentsAreMissing() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setArguments(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST)));
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class);
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(false);
cfg.setZoneGroups(List.of(zoneGroupConfigurationMock));
cfg.setScheduledUpdateIntervalSec(60);
assertThat(cfg.isScheduledUpdateEnabled()).isFalse();
}
@ -354,45 +142,41 @@ public class GeofencingCalculatedFieldConfigurationTest {
@Test
void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() {
var cfg = new GeofencingCalculatedFieldConfiguration();
Argument someDynamicArgument = toArgument("someDynamicArgument", ArgumentType.ATTRIBUTE);
someDynamicArgument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
cfg.setArguments(Map.of("someDynamicArugument", someDynamicArgument));
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class);
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(true);
cfg.setZoneGroups(List.of(zoneGroupConfigurationMock));
cfg.setScheduledUpdateIntervalSec(60);
assertThat(cfg.isScheduledUpdateEnabled()).isTrue();
}
@Test
void validateShouldPassOnMinimalValidConfig() {
void testGetArgumentsOverride() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var args = new HashMap<String, Argument>();
args.put(ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument("latitude", ArgumentType.TS_LATEST));
args.put(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument("longitude", ArgumentType.TS_LATEST));
Argument allowed = toArgument("allowed", ArgumentType.ATTRIBUTE);
var refDynamicSourceConfigurationMock = mock(RelationQueryDynamicSourceConfiguration.class);
allowed.setRefDynamicSourceConfiguration(refDynamicSourceConfigurationMock);
args.put("allowedZones", allowed);
cfg.setArguments(args);
Map<String, GeofencingReportStrategy> zoneGroupReportStrategies =
Map.of("allowedZones", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS);
cfg.setZoneGroupReportStrategies(zoneGroupReportStrategies);
cfg.setCreateRelationsWithMatchedZones(true);
cfg.setZoneRelationType("Contains");
cfg.setZoneRelationDirection(EntitySearchDirection.FROM);
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
cfg.setZoneGroups(List.of(new ZoneGroupConfiguration("allowedZones", "perimeter", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false)));
assertThatCode(cfg::validate).doesNotThrowAnyException();
}
Map<String, Argument> arguments = cfg.getArguments();
private Argument toArgument(String key, ArgumentType type) {
var referencedEntityKey = new ReferencedEntityKey(key, type, null);
return toArgument(referencedEntityKey);
}
assertThat(arguments).isNotNull().hasSize(3);
assertThat(arguments).containsKeys(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, "allowedZones");
Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY);
assertThat(latitudeArgument).isNotNull();
assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull();
assertThat(latitudeArgument.getRefEntityId()).isNull();
assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null));
Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY);
assertThat(longitudeArgument).isNotNull();
assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull();
assertThat(longitudeArgument.getRefEntityId()).isNull();
assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null));
private static Argument toArgument(ReferencedEntityKey referencedEntityKey) {
Argument argument = new Argument();
argument.setRefEntityKey(referencedEntityKey);
return argument;
Argument allowedZonesArgument = arguments.get("allowedZones");
assertThat(allowedZonesArgument).isNotNull();
assertThat(allowedZonesArgument.getRefDynamicSourceConfiguration()).isNull();
assertThat(allowedZonesArgument.getRefEntityId()).isNull();
assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
}
}

17
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java

@ -109,8 +109,6 @@ public class RelationQueryDynamicSourceConfigurationTest {
void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull(List<EntityType> entityTypes) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setEntityTypes(entityTypes);
assertThat(cfg.isSimpleRelation()).isTrue();
}
@ -118,17 +116,6 @@ public class RelationQueryDynamicSourceConfigurationTest {
void isSimpleRelationFalseWhenMaxLevelNotOne() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
cfg.setEntityTypes(null);
assertThat(cfg.isSimpleRelation()).isFalse();
}
@Test
void isSimpleRelationFalseWhenEntityTypesProvided() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setEntityTypes(List.of(EntityType.DEVICE));
assertThat(cfg.isSimpleRelation()).isFalse();
}
@ -140,7 +127,6 @@ public class RelationQueryDynamicSourceConfigurationTest {
cfg.setFetchLastLevelOnly(false);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setEntityTypes(entityTypes);
assertThatThrownBy(() -> cfg.toEntityRelationsQuery(rootEntityId))
.isInstanceOf(IllegalArgumentException.class)
@ -154,7 +140,6 @@ public class RelationQueryDynamicSourceConfigurationTest {
cfg.setFetchLastLevelOnly(true);
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.MANAGES_TYPE);
cfg.setEntityTypes(List.of(EntityType.DEVICE, EntityType.ASSET));
var query = cfg.toEntityRelationsQuery(rootEntityId);
@ -170,7 +155,6 @@ public class RelationQueryDynamicSourceConfigurationTest {
assertThat(query.getFilters().get(0)).isInstanceOf(RelationEntityTypeFilter.class);
RelationEntityTypeFilter filter = query.getFilters().get(0);
assertThat(filter.getRelationType()).isEqualTo(EntityRelation.MANAGES_TYPE);
assertThat(filter.getEntityTypes()).containsExactly(EntityType.DEVICE, EntityType.ASSET);
}
@Test
@ -206,7 +190,6 @@ public class RelationQueryDynamicSourceConfigurationTest {
cfg.setFetchLastLevelOnly(false);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setEntityTypes(List.of(EntityType.DEVICE));
assertThatCode(cfg::validate).doesNotThrowAnyException();
}

80
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java

@ -0,0 +1,80 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
public class EntityCoordinatesTest {
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) {
var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude");
assertThatThrownBy(entityCoordinates::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity coordinates latitude key name must be specified!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) {
var entityCoordinates = new EntityCoordinates("latitude", longitudeKey);
assertThatThrownBy(entityCoordinates::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity coordinates longitude key name must be specified!");
}
@Test
void validateShouldPassOnMinimalValidConfig() {
var entityCoordinates = new EntityCoordinates("latitude", "longitude");
assertThatCode(entityCoordinates::validate).doesNotThrowAnyException();
}
@Test
void validateToArgumentsMethodCallWithoutRefEntityId() {
var entityCoordinates = new EntityCoordinates("xPos", "yPos");
var arguments = entityCoordinates.toArguments();
assertThat(arguments).isNotNull().hasSize(2);
Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY);
assertThat(latitudeArgument).isNotNull();
assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("xPos", ArgumentType.TS_LATEST, null));
assertThat(latitudeArgument.getRefEntityId()).isNull();
assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull();
Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY);
assertThat(longitudeArgument).isNotNull();
assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("yPos", ArgumentType.TS_LATEST, null));
assertThat(longitudeArgument.getRefEntityId()).isNull();
assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull();
}
}

21
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java

@ -0,0 +1,21 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.geofencing;
// TODO: add tests
public class ZoneGroupConfigurationTest {
}

4
dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java

@ -21,8 +21,8 @@ import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduleSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CalculatedFieldLinkId;
import org.thingsboard.server.common.data.id.EntityId;
@ -94,7 +94,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements
}
private void updatedSchedulingConfiguration(CalculatedField calculatedField) {
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) {
if (calculatedField.getConfiguration() instanceof ScheduleSupportedCalculatedFieldConfiguration configuration) {
if (!configuration.isScheduledUpdateEnabled()) {
return;
}

103
dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java

@ -29,12 +29,13 @@ import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@ -43,10 +44,12 @@ import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldServiceTest extends AbstractServiceTest {
@ -105,27 +108,14 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
Argument lat = new Argument();
lat.setRefEntityId(device.getId());
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
Argument lon = new Argument();
lon.setRefEntityId(device.getId());
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
entityCoordinates.setRefEntityId(device.getId());
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — no DYNAMIC configuration, so no scheduling even if the scheduled interval is set
Argument allowed = new Argument();
lat.setRefEntityId(device.getId());
allowed.setRefEntityKey(new ReferencedEntityKey("allowed", ArgumentType.ATTRIBUTE, null));
cfg.setArguments(Map.of(
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
"allowed", allowed
));
// Matching zone-group configuration
cfg.setZoneGroupReportStrategies(Map.of("allowed", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS));
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefEntityId(device.getId());
cfg.setZoneGroups(List.of(zoneGroupConfiguration));
// Set a scheduled interval to some value
cfg.setScheduledUpdateIntervalSec(600);
@ -141,9 +131,14 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
CalculatedField saved = calculatedFieldService.save(cf);
assertThat(saved).isNotNull();
assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration();
// Assert: the interval is saved, but scheduling is not enabled
int savedInterval = saved.getConfiguration().getScheduledUpdateIntervalSec();
boolean scheduledUpdateEnabled = saved.getConfiguration().isScheduledUpdateEnabled();
int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateIntervalSec();
boolean scheduledUpdateEnabled = geofencingCalculatedFieldConfiguration.isScheduledUpdateEnabled();
assertThat(savedInterval).isEqualTo(600);
assertThat(scheduledUpdateEnabled).isFalse();
@ -160,31 +155,18 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
Argument lat = new Argument();
lat.setRefEntityId(device.getId());
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
Argument lon = new Argument();
lon.setRefEntityId(device.getId());
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
entityCoordinates.setRefEntityId(device.getId());
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
Argument allowed = new Argument();
allowed.setRefEntityKey(new ReferencedEntityKey("allowed", ArgumentType.ATTRIBUTE, null));
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(1);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
allowed.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setArguments(Map.of(
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
"allowed", allowed
));
// Matching zone-group configuration
cfg.setZoneGroupReportStrategies(Map.of("allowed", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS));
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(List.of(zoneGroupConfiguration));
// Enable scheduling with an interval below tenant min
cfg.setScheduledUpdateIntervalSec(600);
@ -200,8 +182,13 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
CalculatedField saved = calculatedFieldService.save(cf);
assertThat(saved).isNotNull();
assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration();
// Assert: the interval is clamped up to tenant profile min
int savedInterval = saved.getConfiguration().getScheduledUpdateIntervalSec();
int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateIntervalSec();
int min = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
@ -220,31 +207,18 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
Argument lat = new Argument();
lat.setRefEntityId(device.getId());
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
Argument lon = new Argument();
lon.setRefEntityId(device.getId());
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
entityCoordinates.setRefEntityId(device.getId());
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
Argument allowed = new Argument();
allowed.setRefEntityKey(new ReferencedEntityKey("allowed", ArgumentType.ATTRIBUTE, null));
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(1);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
allowed.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setArguments(Map.of(
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
"allowed", allowed
));
// Matching zone-group configuration
cfg.setZoneGroupReportStrategies(Map.of("allowed", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS));
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(List.of(zoneGroupConfiguration));
// Get tenant profile min.
int min = tbTenantProfileCache.get(tenantId)
@ -267,8 +241,13 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
CalculatedField saved = calculatedFieldService.save(cf);
assertThat(saved).isNotNull();
assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class);
var geofencingCalculatedFieldConfiguration = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration();
// Assert: the interval is clamped up to tenant profile min (or stays >= original if already >= min)
int savedInterval = saved.getConfiguration().getScheduledUpdateIntervalSec();
int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateIntervalSec();
assertThat(savedInterval).isEqualTo(valueFromConfig);
calculatedFieldService.deleteCalculatedField(tenantId, saved.getId());

83
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java

@ -37,6 +37,8 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration;
import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration;
@ -52,6 +54,7 @@ import org.thingsboard.server.msa.AbstractContainerTest;
import org.thingsboard.server.msa.ui.utils.EntityPrototypes;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@ -71,17 +74,17 @@ public class CalculatedFieldTest extends AbstractContainerTest {
private final String deviceToken = "zmzURIVRsq3lvnTP2XBE";
private final String exampleScript = "var avgTemperature = temperature.mean(); // Get average temperature\n" +
" var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin\n" +
"\n" +
" // Estimate air pressure based on altitude\n" +
" var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);\n" +
"\n" +
" // Air density formula\n" +
" var airDensity = pressure / (287.05 * temperatureK);\n" +
"\n" +
" return {\n" +
" \"airDensity\": toFixed(airDensity, 2)\n" +
" };";
" var temperatureK = (avgTemperature - 32) * (5 / 9) + 273.15; // Convert Fahrenheit to Kelvin\n" +
"\n" +
" // Estimate air pressure based on altitude\n" +
" var pressure = 101325 * Math.pow((1 - 2.25577e-5 * altitude), 5.25588);\n" +
"\n" +
" // Air density formula\n" +
" var airDensity = pressure / (287.05 * temperatureK);\n" +
"\n" +
" return {\n" +
" \"airDensity\": toFixed(airDensity, 2)\n" +
" };";
private TenantId tenantId;
private UserId tenantAdminId;
@ -152,8 +155,9 @@ public class CalculatedFieldTest extends AbstractContainerTest {
testRestClient.getAndSetUserToken(tenantAdminId);
CalculatedField savedCalculatedField = createSimpleCalculatedField();
assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue();
Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T");
Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T");
savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, SERVER_SCOPE));
testRestClient.postCalculatedField(savedCalculatedField);
@ -200,9 +204,10 @@ public class CalculatedFieldTest extends AbstractContainerTest {
testRestClient.getAndSetUserToken(tenantAdminId);
CalculatedField savedCalculatedField = createSimpleCalculatedField();
assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue();
savedCalculatedField.setName("F to C");
savedCalculatedField.getConfiguration().setExpression("(T - 32) / 1.8");
((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).setExpression("(T - 32) / 1.8");
testRestClient.postCalculatedField(savedCalculatedField);
await().alias("update CF expression -> perform calculation with new expression").atMost(TIMEOUT, TimeUnit.SECONDS)
@ -357,40 +362,28 @@ public class CalculatedFieldTest extends AbstractContainerTest {
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
Argument lat = new Argument();
lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null));
Argument lon = new Argument();
lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null));
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Dynamic groups via relations
Argument allowedArg = new Argument();
var dynAllowed = new RelationQueryDynamicSourceConfiguration();
dynAllowed.setDirection(EntitySearchDirection.FROM);
dynAllowed.setRelationType("AllowedZone");
dynAllowed.setMaxLevel(1);
dynAllowed.setFetchLastLevelOnly(true);
allowedArg.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, SERVER_SCOPE));
allowedArg.setRefDynamicSourceConfiguration(dynAllowed);
Argument restrictedArg = new Argument();
var dynRestricted = new RelationQueryDynamicSourceConfiguration();
dynRestricted.setDirection(EntitySearchDirection.FROM);
dynRestricted.setRelationType("RestrictedZone");
dynRestricted.setMaxLevel(1);
dynRestricted.setFetchLastLevelOnly(true);
restrictedArg.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, SERVER_SCOPE));
restrictedArg.setRefDynamicSourceConfiguration(dynRestricted);
cfg.setArguments(Map.of(
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat,
GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon,
"allowedZones", allowedArg,
"restrictedZones", restrictedArg
));
cfg.setZoneGroupReportStrategies(Map.of(
"allowedZones", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS,
"restrictedZones", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS
));
ZoneGroupConfiguration allowedZoneGroupConfiguration = new ZoneGroupConfiguration("allowedZones", "zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var allowedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
allowedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
allowedDynamicSourceConfiguration.setMaxLevel(1);
allowedDynamicSourceConfiguration.setFetchLastLevelOnly(true);
allowedDynamicSourceConfiguration.setRelationType("AllowedZone");
allowedZoneGroupConfiguration.setRefDynamicSourceConfiguration(allowedDynamicSourceConfiguration);
ZoneGroupConfiguration restrictedZoneGroupConfiguration = new ZoneGroupConfiguration("restrictedZones", "zone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var restrictedDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
restrictedDynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
restrictedDynamicSourceConfiguration.setMaxLevel(1);
restrictedDynamicSourceConfiguration.setFetchLastLevelOnly(true);
restrictedDynamicSourceConfiguration.setRelationType("RestrictedZone");
restrictedZoneGroupConfiguration.setRefDynamicSourceConfiguration(restrictedDynamicSourceConfiguration);
cfg.setZoneGroups(List.of(allowedZoneGroupConfiguration, restrictedZoneGroupConfiguration));
Output out = new Output();
out.setType(OutputType.ATTRIBUTES);
out.setScope(SERVER_SCOPE);

Loading…
Cancel
Save