Added support to use only one zone type instead of two + minor validation fixes

This commit is contained in:
dshvaika 2025-08-06 16:12:33 +03:00
parent 82cca8c665
commit ede9fd5e05
5 changed files with 77 additions and 35 deletions

View File

@ -18,6 +18,7 @@ package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.geo.Coordinates; import org.thingsboard.common.util.geo.Coordinates;
@ -31,12 +32,13 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ALLOWED_ZONES_ARGUMENT_KEY;
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_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.GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.RESTRICTED_ZONES_ARGUMENT_KEY; import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.RESTRICTED_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ALLOWED_ZONES_ARGUMENT_KEY;
@Data @Data
@AllArgsConstructor
public class GeofencingCalculatedFieldState implements CalculatedFieldState { public class GeofencingCalculatedFieldState implements CalculatedFieldState {
private List<String> requiredArguments; private List<String> requiredArguments;
@ -46,9 +48,8 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
private long latestTimestamp = -1; private long latestTimestamp = -1;
public GeofencingCalculatedFieldState() { public GeofencingCalculatedFieldState() {
this(List.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ALLOWED_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY)); this(new ArrayList<>(), new HashMap<>(), false, -1);
} }
public GeofencingCalculatedFieldState(List<String> argNames) { public GeofencingCalculatedFieldState(List<String> argNames) {
@ -112,8 +113,12 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
@Override @Override
public ListenableFuture<List<CalculatedFieldResult>> performCalculation(CalculatedFieldCtx ctx) { public ListenableFuture<List<CalculatedFieldResult>> performCalculation(CalculatedFieldCtx ctx) {
List<CalculatedFieldResult> savedZonesStatesResults = updateGeofencingZonesState(ctx, false); double latitude = (double) arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY).getValue();
List<CalculatedFieldResult> restrictedZonesStatesResults = updateGeofencingZonesState(ctx, true); double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue();
Coordinates entityCoordinates = new Coordinates(latitude, longitude);
List<CalculatedFieldResult> savedZonesStatesResults = updateGeofencingZonesState(ctx, entityCoordinates, false);
List<CalculatedFieldResult> restrictedZonesStatesResults = updateGeofencingZonesState(ctx, entityCoordinates, true);
List<CalculatedFieldResult> allZoneStatesResults = List<CalculatedFieldResult> allZoneStatesResults =
new ArrayList<>(savedZonesStatesResults.size() + restrictedZonesStatesResults.size()); new ArrayList<>(savedZonesStatesResults.size() + restrictedZonesStatesResults.size());
@ -137,15 +142,15 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
} }
} }
private List<CalculatedFieldResult> updateGeofencingZonesState(CalculatedFieldCtx ctx, boolean restricted) { private List<CalculatedFieldResult> updateGeofencingZonesState(CalculatedFieldCtx ctx, Coordinates entityCoordinates, boolean restricted) {
var results = new ArrayList<CalculatedFieldResult>();
double latitude = (double) arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY).getValue();
double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue();
Coordinates entityCoordinates = new Coordinates(latitude, longitude);
String zoneKey = restricted ? RESTRICTED_ZONES_ARGUMENT_KEY : ALLOWED_ZONES_ARGUMENT_KEY; String zoneKey = restricted ? RESTRICTED_ZONES_ARGUMENT_KEY : ALLOWED_ZONES_ARGUMENT_KEY;
GeofencingArgumentEntry zonesEntry = (GeofencingArgumentEntry) arguments.get(zoneKey); GeofencingArgumentEntry zonesEntry = (GeofencingArgumentEntry) arguments.get(zoneKey);
if (zonesEntry == null) {
return List.of();
}
var results = new ArrayList<CalculatedFieldResult>();
for (var zoneEntry : zonesEntry.getZoneStates().entrySet()) { for (var zoneEntry : zonesEntry.getZoneStates().entrySet()) {
GeofencingZoneState state = zoneEntry.getValue(); GeofencingZoneState state = zoneEntry.getValue();
String event = state.evaluate(entityCoordinates); String event = state.evaluate(entityCoordinates);

View File

@ -83,6 +83,9 @@ public class GeofencingZoneState {
public String evaluate(Coordinates entityCoordinates) { public String evaluate(Coordinates entityCoordinates) {
boolean inside = perimeterDefinition.checkMatches(entityCoordinates); boolean inside = perimeterDefinition.checkMatches(entityCoordinates);
// TODO: maybe handle this.inside == null as ENTERED or OUTSIDE.
// Since if this.inside == null then we don't have a state for this zone yet
// and logically say that we are OUTSIDE instead of LEFT.
if (this.inside == null || this.inside != inside) { if (this.inside == null || this.inside != inside) {
this.inside = inside; this.inside = inside;
return inside ? GpsGeofencingEvents.ENTERED : GpsGeofencingEvents.LEFT; return inside ? GpsGeofencingEvents.ENTERED : GpsGeofencingEvents.LEFT;

View File

@ -19,6 +19,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import java.util.Map;
import java.util.Set; import java.util.Set;
import static org.thingsboard.server.common.data.cf.configuration.CFArgumentDynamicSourceType.RELATION_QUERY; import static org.thingsboard.server.common.data.cf.configuration.CFArgumentDynamicSourceType.RELATION_QUERY;
@ -32,13 +33,18 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
public static final String ALLOWED_ZONES_ARGUMENT_KEY = "allowedZones"; public static final String ALLOWED_ZONES_ARGUMENT_KEY = "allowedZones";
public static final String RESTRICTED_ZONES_ARGUMENT_KEY = "restrictedZones"; public static final String RESTRICTED_ZONES_ARGUMENT_KEY = "restrictedZones";
private static final Set<String> requiredKeys = Set.of( private static final Set<String> allowedKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY,
ALLOWED_ZONES_ARGUMENT_KEY, ALLOWED_ZONES_ARGUMENT_KEY,
RESTRICTED_ZONES_ARGUMENT_KEY RESTRICTED_ZONES_ARGUMENT_KEY
); );
private static final Set<String> requiredKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY
);
@Override @Override
public CalculatedFieldType getType() { public CalculatedFieldType getType() {
return CalculatedFieldType.GEOFENCING; return CalculatedFieldType.GEOFENCING;
@ -47,44 +53,72 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
// TODO: update validate method in PE version. // TODO: update validate method in PE version.
@Override @Override
public void validate() { public void validate() {
if (arguments == null || arguments.size() != 4) { if (arguments == null) {
throw new IllegalArgumentException("Geofencing calculated field configuration must contain exactly 4 arguments: " + requiredKeys); throw new IllegalArgumentException("Geofencing calculated field arguments are empty!");
} }
// Check key count
if (arguments.size() < 3 || arguments.size() > 4) {
throw new IllegalArgumentException("Geofencing calculated field must contain 3 or 4 arguments: " + allowedKeys);
}
// Check for unsupported argument keys
for (String key : arguments.keySet()) {
if (!allowedKeys.contains(key)) {
throw new IllegalArgumentException("Unsupported argument key: '" + key + "'. Allowed keys: " + allowedKeys);
}
}
// Check required fields: latitude and longitude
for (String requiredKey : requiredKeys) { for (String requiredKey : requiredKeys) {
Argument argument = arguments.get(requiredKey); if (!arguments.containsKey(requiredKey)) {
if (argument == null) {
throw new IllegalArgumentException("Missing required argument: " + requiredKey); throw new IllegalArgumentException("Missing required argument: " + requiredKey);
} }
}
// Ensure at least one of the zone types is configured
boolean hasAllowedZones = arguments.containsKey(ALLOWED_ZONES_ARGUMENT_KEY);
boolean hasRestrictedZones = arguments.containsKey(RESTRICTED_ZONES_ARGUMENT_KEY);
if (!hasAllowedZones && !hasRestrictedZones) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least one of the following arguments: 'allowedZones' or 'restrictedZones'");
}
for (Map.Entry<String, Argument> entry : arguments.entrySet()) {
String argumentKey = entry.getKey();
Argument argument = entry.getValue();
if (argument == null) {
throw new IllegalArgumentException("Missing required argument: " + argumentKey);
}
ReferencedEntityKey refEntityKey = argument.getRefEntityKey(); ReferencedEntityKey refEntityKey = argument.getRefEntityKey();
if (refEntityKey == null || refEntityKey.getType() == null) { if (refEntityKey == null || refEntityKey.getType() == null) {
throw new IllegalArgumentException("Missing or invalid reference entity key for argument: " + requiredKey); throw new IllegalArgumentException("Missing or invalid reference entity key for argument: " + argumentKey);
} }
switch (requiredKey) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { switch (argumentKey) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) { if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument: '" + requiredKey + "' must be set to " + ArgumentType.TS_LATEST + " type!"); throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type TS_LATEST.");
} }
var dynamicSource = argument.getRefDynamicSource(); if (argument.getRefDynamicSource() != null) {
if (dynamicSource != null) { throw new IllegalArgumentException("Dynamic source is not allowed for argument: '" + argumentKey + "'.");
String test = "test";
throw new IllegalArgumentException("Dynamic source configuration is forbidden for '" + requiredKey + "' argument. " +
"Only '" + ALLOWED_ZONES_ARGUMENT_KEY + "' and '" + RESTRICTED_ZONES_ARGUMENT_KEY + "' " +
"may use dynamic source configuration.");
} }
} }
case ALLOWED_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY -> { case ALLOWED_ZONES_ARGUMENT_KEY,
RESTRICTED_ZONES_ARGUMENT_KEY -> {
if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) { if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument: '" + requiredKey + "' must be set to " + ArgumentType.ATTRIBUTE + " type!"); throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type ATTRIBUTE.");
} }
var dynamicSource = argument.getRefDynamicSource(); var dynamicSource = argument.getRefDynamicSource();
if (dynamicSource == null) { if (dynamicSource == null) {
continue; continue;
} }
if (!RELATION_QUERY.equals(dynamicSource)) { if (!RELATION_QUERY.equals(dynamicSource)) {
throw new IllegalArgumentException("Only relation query dynamic source is supported for argument: " + requiredKey); throw new IllegalArgumentException("Only relation query dynamic source is supported for argument: '" + argumentKey + "'.");
} }
if (argument.getRefDynamicSourceConfiguration() == null) { if (argument.getRefDynamicSourceConfiguration() == null) {
throw new IllegalArgumentException("Missing dynamic source configuration for: " + requiredKey); throw new IllegalArgumentException("Missing dynamic source configuration for argument: '" + argumentKey + "'.");
} }
argument.getRefDynamicSourceConfiguration().validate(); argument.getRefDynamicSourceConfiguration().validate();
} }

View File

@ -34,7 +34,7 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
private boolean fetchLastLevelOnly; private boolean fetchLastLevelOnly;
private EntitySearchDirection direction; private EntitySearchDirection direction;
private String relationType; private String relationType;
private List<EntityType> profiles; private List<EntityType> entityTypes;
@Override @Override
public CFArgumentDynamicSourceType getType() { public CFArgumentDynamicSourceType getType() {
@ -56,7 +56,7 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
@Override @Override
public boolean isSimpleRelation() { public boolean isSimpleRelation() {
return maxLevel == 1 && (profiles == null || profiles.isEmpty()); return maxLevel == 1 && (entityTypes == null || entityTypes.isEmpty());
} }
@Override @Override
@ -66,7 +66,7 @@ public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynami
} }
var entityRelationsQuery = new EntityRelationsQuery(); var entityRelationsQuery = new EntityRelationsQuery();
entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly)); entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly));
entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, profiles))); entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, entityTypes)));
return entityRelationsQuery; return entityRelationsQuery;
} }

View File

@ -70,8 +70,8 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
if (maxArgumentsPerCF <= 0) { if (maxArgumentsPerCF <= 0) {
return; return;
} }
if (CalculatedFieldType.GEOFENCING.equals(calculatedField.getType()) && maxArgumentsPerCF < 4) { if (CalculatedFieldType.GEOFENCING.equals(calculatedField.getType()) && maxArgumentsPerCF < 3) {
throw new DataValidationException("Geofencing calculated field requires 4 arguments, but the system limit is " + throw new DataValidationException("Geofencing calculated field requires at least 3 arguments, but the system limit is " +
maxArgumentsPerCF + ". Contact your administrator to increase the limit." maxArgumentsPerCF + ". Contact your administrator to increase the limit."
); );
} }