Updated to use zone groups

This commit is contained in:
dshvaika 2025-08-07 15:51:19 +03:00
parent ede9fd5e05
commit c783176e71
10 changed files with 295 additions and 137 deletions

View File

@ -35,6 +35,8 @@ import org.thingsboard.server.common.data.StringUtils;
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.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@ -95,8 +97,6 @@ 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.GeofencingCalculatedFieldConfiguration.RESTRICTED_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.GeofencingCalculatedFieldConfiguration.ALLOWED_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent
@ -132,16 +132,17 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>();
if (ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) {
// Ignoring any other arguments except ENTITY_ID_LATITUDE_ARGUMENT_KEY,
// ENTITY_ID_LONGITUDE_ARGUMENT_KEY, SAVE_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY.
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
var zoneGroupConfigs = configuration.getGeofencingZoneGroupConfigurations();
for (var entry : ctx.getArguments().entrySet()) {
switch (entry.getKey()) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY ->
argFutures.put(entry.getKey(), fetchKvEntry(ctx.getTenantId(), resolveEntityId(entityId, entry), entry.getValue()));
case ALLOWED_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY -> {
default -> {
var zoneGroupConfiguration = zoneGroupConfigs.get(entry.getKey());
var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry);
argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds ->
fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue()), calculatedFieldCallbackExecutor));
fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue(), zoneGroupConfiguration), calculatedFieldCallbackExecutor));
}
}
}
@ -304,7 +305,8 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
};
}
private ListenableFuture<ArgumentEntry> fetchGeofencingKvEntry(TenantId tenantId, List<EntityId> geofencingEntities, Argument argument) {
private ListenableFuture<ArgumentEntry> fetchGeofencingKvEntry(TenantId tenantId, List<EntityId> geofencingEntities,
Argument argument, GeofencingZoneGroupConfiguration zoneGroupConfiguration) {
if (argument.getRefEntityKey().getType() != ArgumentType.ATTRIBUTE) {
throw new IllegalStateException("Unsupported argument key type: " + argument.getRefEntityKey().getType());
}
@ -326,7 +328,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
ListenableFuture<List<Map.Entry<EntityId, AttributeKvEntry>>> allFutures = Futures.allAsList(kvFutures);
return Futures.transform(allFutures, entries -> ArgumentEntry.createGeofencingValueArgument(entries.stream()
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))),
.collect(Collectors.toMap(Entry::getKey, Entry::getValue)), zoneGroupConfiguration),
calculatedFieldCallbackExecutor
);
}

View File

@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
@ -61,8 +62,8 @@ public interface ArgumentEntry {
return new TsRollingArgumentEntry(kvEntries, limit, timeWindow);
}
static ArgumentEntry createGeofencingValueArgument(Map<EntityId, KvEntry> entityIdkvEntryMap) {
return new GeofencingArgumentEntry(entityIdkvEntryMap);
static ArgumentEntry createGeofencingValueArgument(Map<EntityId, KvEntry> entityIdkvEntryMap, GeofencingZoneGroupConfiguration zoneGroupConfiguration) {
return new GeofencingArgumentEntry(entityIdkvEntryMap, zoneGroupConfiguration);
}
}

View File

@ -19,6 +19,7 @@ import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg;
import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
@ -31,13 +32,17 @@ import java.util.stream.Collectors;
public class GeofencingArgumentEntry implements ArgumentEntry {
private Map<EntityId, GeofencingZoneState> zoneStates;
private GeofencingZoneGroupConfiguration zoneGroupConfiguration;
private boolean forceResetPrevious;
public GeofencingArgumentEntry() {
}
public GeofencingArgumentEntry(Map<EntityId, KvEntry> entityIdKvEntryMap) {
this.zoneStates = toZones(entityIdKvEntryMap);
public GeofencingArgumentEntry(Map<EntityId, KvEntry> entityIdkvEntryMap,
GeofencingZoneGroupConfiguration zoneGroupConfiguration) {
this.zoneStates = toZones(entityIdkvEntryMap);
this.zoneGroupConfiguration = zoneGroupConfiguration;
}
@Override

View File

@ -23,6 +23,7 @@ import lombok.Data;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.geo.Coordinates;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.GeofencingEvent;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.utils.CalculatedFieldUtils;
@ -31,11 +32,13 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
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_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.coordinateKeys;
@Data
@AllArgsConstructor
@ -70,7 +73,7 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
boolean stateUpdated = false;
for (Map.Entry<String, ArgumentEntry> entry : argumentValues.entrySet()) {
for (var entry : argumentValues.entrySet()) {
String key = entry.getKey();
ArgumentEntry newEntry = entry.getValue();
@ -80,26 +83,22 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
boolean entryUpdated;
if (existingEntry == null || newEntry.isForceResetPrevious()) {
switch (key) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY:
case ENTITY_ID_LONGITUDE_ARGUMENT_KEY:
entryUpdated = switch (key) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) {
throw new IllegalArgumentException(key + " argument must be a single value argument.");
}
arguments.put(key, singleValueArgumentEntry);
entryUpdated = true;
break;
case ALLOWED_ZONES_ARGUMENT_KEY:
case RESTRICTED_ZONES_ARGUMENT_KEY:
yield true;
}
default -> {
if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException(key + " argument must be a geofencing argument entry.");
}
arguments.put(key, geofencingArgumentEntry);
entryUpdated = true;
break;
default:
throw new IllegalArgumentException("Unsupported argument: " + key);
}
yield true;
}
};
} else {
entryUpdated = existingEntry.updateEntry(newEntry);
}
@ -111,21 +110,60 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
}
// TODO: Probably returning list of CalculatedFieldResult no needed anymore,
// since logic changed to use zone groups with telemetry prefix.
@Override
public ListenableFuture<List<CalculatedFieldResult>> performCalculation(CalculatedFieldCtx ctx) {
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);
List<CalculatedFieldResult> savedZonesStatesResults = updateGeofencingZonesState(ctx, entityCoordinates, false);
List<CalculatedFieldResult> restrictedZonesStatesResults = updateGeofencingZonesState(ctx, entityCoordinates, true);
ObjectNode resultNode = JacksonUtil.newObjectNode();
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
var zoneGroupConfig = argumentEntry.getZoneGroupConfiguration();
Set<GeofencingEvent> zoneEvents = argumentEntry.getZoneStates()
.values()
.stream()
.map(zoneState -> zoneState.evaluate(entityCoordinates))
.collect(Collectors.toSet());
aggregateZoneGroupEvent(zoneEvents).ifPresent(event ->
resultNode.put(zoneGroupConfig.getReportTelemetryPrefix() + "Event", event.name())
);
});
return Futures.immediateFuture(List.of(new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode)));
}
List<CalculatedFieldResult> allZoneStatesResults =
new ArrayList<>(savedZonesStatesResults.size() + restrictedZonesStatesResults.size());
allZoneStatesResults.addAll(savedZonesStatesResults);
allZoneStatesResults.addAll(restrictedZonesStatesResults);
private Optional<GeofencingEvent> aggregateZoneGroupEvent(Set<GeofencingEvent> zoneEvents) {
boolean hasEntered = false;
boolean hasLeft = false;
boolean hasInside = false;
boolean hasOutside = false;
return Futures.immediateFuture(allZoneStatesResults);
for (GeofencingEvent event : zoneEvents) {
if (event == null) {
continue;
}
switch (event) {
case ENTERED -> hasEntered = true;
case LEFT -> hasLeft = true;
case INSIDE -> hasInside = true;
case OUTSIDE -> hasOutside = true;
}
}
if (hasOutside && !hasInside && !hasEntered && !hasLeft) {
return Optional.of(GeofencingEvent.OUTSIDE);
}
if (hasLeft && !hasEntered && !hasInside) {
return Optional.of(GeofencingEvent.LEFT);
}
if (hasEntered && !hasLeft && !hasInside) {
return Optional.of(GeofencingEvent.ENTERED);
}
if (hasInside || hasEntered) {
return Optional.of(GeofencingEvent.INSIDE);
}
return Optional.empty();
}
@Override
@ -142,26 +180,12 @@ public class GeofencingCalculatedFieldState implements CalculatedFieldState {
}
}
private List<CalculatedFieldResult> updateGeofencingZonesState(CalculatedFieldCtx ctx, Coordinates entityCoordinates, boolean restricted) {
String zoneKey = restricted ? RESTRICTED_ZONES_ARGUMENT_KEY : ALLOWED_ZONES_ARGUMENT_KEY;
GeofencingArgumentEntry zonesEntry = (GeofencingArgumentEntry) arguments.get(zoneKey);
if (zonesEntry == null) {
return List.of();
}
var results = new ArrayList<CalculatedFieldResult>();
for (var zoneEntry : zonesEntry.getZoneStates().entrySet()) {
GeofencingZoneState state = zoneEntry.getValue();
String event = state.evaluate(entityCoordinates);
ObjectNode stateNode = JacksonUtil.newObjectNode();
stateNode.put("entityId", ctx.getEntityId().toString());
stateNode.put("zoneId", state.getZoneId().toString());
stateNode.put("restricted", restricted);
stateNode.put("event", event);
results.add(new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), stateNode));
}
return results;
// TODO: Create a new class field to not do this on each calculation.
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() {
return arguments.entrySet()
.stream()
.filter(entry -> !coordinateKeys.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue()));
}
}

View File

@ -20,7 +20,7 @@ import lombok.EqualsAndHashCode;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.geo.Coordinates;
import org.thingsboard.common.util.geo.PerimeterDefinition;
import org.thingsboard.rule.engine.util.GpsGeofencingEvents;
import org.thingsboard.server.common.data.cf.configuration.GeofencingEvent;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
@ -81,16 +81,20 @@ public class GeofencingZoneState {
return false;
}
public String evaluate(Coordinates entityCoordinates) {
public GeofencingEvent evaluate(Coordinates 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) {
// Initial evaluation no prior state
if (this.inside == null) {
this.inside = inside;
return inside ? GpsGeofencingEvents.ENTERED : GpsGeofencingEvents.LEFT;
return inside ? GeofencingEvent.ENTERED : GeofencingEvent.OUTSIDE;
}
return inside ? GpsGeofencingEvents.INSIDE : GpsGeofencingEvents.OUTSIDE;
// State changed
if (this.inside != inside) {
this.inside = inside;
return inside ? GeofencingEvent.ENTERED : GeofencingEvent.LEFT;
}
// State unchanged
return inside ? GeofencingEvent.INSIDE : GeofencingEvent.OUTSIDE;
}
}

View File

@ -18,6 +18,8 @@ package org.thingsboard.server.utils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.GeofencingEvent;
import org.thingsboard.server.common.data.cf.configuration.GeofencingZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
@ -28,6 +30,7 @@ import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntit
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingArgumentProto;
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingEventProto;
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto;
import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto;
@ -45,6 +48,7 @@ import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
@ -123,12 +127,15 @@ public class CalculatedFieldUtils {
private static GeofencingArgumentProto toGeofencingArgumentProto(String argName, GeofencingArgumentEntry geofencingArgumentEntry) {
GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder()
.setArgName(argName);
var zoneGroupConfiguration = geofencingArgumentEntry.getZoneGroupConfiguration();
Map<EntityId, GeofencingZoneState> zoneStates = geofencingArgumentEntry.getZoneStates();
zoneStates.forEach((entityId, zoneState) -> {
builder.addZones(toGeofencingZoneProto(entityId, zoneState));
});
GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder()
.setArgName(argName)
.setTelemetryPrefix(zoneGroupConfiguration.getReportTelemetryPrefix());
zoneStates.forEach((entityId, zoneState) ->
builder.addZones(toGeofencingZoneProto(entityId, zoneState)));
zoneGroupConfiguration.getReportEvents().forEach(event ->
builder.addReportEvents(GeofencingEventProto.forNumber(event.getProtoNumber())));
return builder.build();
}
@ -206,8 +213,14 @@ public class CalculatedFieldUtils {
.stream()
.map(GeofencingZoneState::new)
.collect(Collectors.toMap(GeofencingZoneState::getZoneId, Function.identity()));
List<GeofencingEvent> geofencingEvents = proto.getReportEventsList()
.stream()
.map(geofencingEventProto -> GeofencingEvent.fromProtoNumber(geofencingEventProto.getNumber()))
.toList();
var zoneGroupConfiguration = new GeofencingZoneGroupConfiguration(proto.getTelemetryPrefix(), geofencingEvents);
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
geofencingArgumentEntry.setZoneStates(zoneStates);
geofencingArgumentEntry.setZoneGroupConfiguration(zoneGroupConfiguration);
return geofencingArgumentEntry;
}

View File

@ -17,10 +17,14 @@ package org.thingsboard.server.common.data.cf.configuration;
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.util.CollectionsUtil;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.configuration.CFArgumentDynamicSourceType.RELATION_QUERY;
@ -30,21 +34,14 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
public static final String ALLOWED_ZONES_ARGUMENT_KEY = "allowedZones";
public static final String RESTRICTED_ZONES_ARGUMENT_KEY = "restrictedZones";
private static final Set<String> allowedKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY,
ALLOWED_ZONES_ARGUMENT_KEY,
RESTRICTED_ZONES_ARGUMENT_KEY
);
private static final Set<String> requiredKeys = Set.of(
public static final Set<String> coordinateKeys = Set.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY
);
Map<String, GeofencingZoneGroupConfiguration> geofencingZoneGroupConfigurations;
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.GEOFENCING;
@ -56,74 +53,100 @@ public class GeofencingCalculatedFieldConfiguration extends BaseCalculatedFieldC
if (arguments == null) {
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);
if (arguments.size() < 3) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least 3 arguments!");
}
if (arguments.size() > 5) {
throw new IllegalArgumentException("Geofencing calculated field size exceeds limit of 5 arguments!");
}
validateCoordinateArguments();
// Check for unsupported argument keys
for (String key : arguments.keySet()) {
if (!allowedKeys.contains(key)) {
throw new IllegalArgumentException("Unsupported argument key: '" + key + "'. Allowed keys: " + allowedKeys);
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);
}
private void validateZoneGroupConfigurations(Map<String, Argument> zoneGroupsArguments) {
if (geofencingZoneGroupConfigurations == null) {
throw new IllegalArgumentException("Geofencing calculated field zone group configurations are empty!");
}
Set<String> usedPrefixes = new HashSet<>();
geofencingZoneGroupConfigurations.forEach((zoneGroupName, config) -> {
Argument zoneGroupArgument = zoneGroupsArguments.get(zoneGroupName);
if (zoneGroupArgument == null) {
throw new IllegalArgumentException("Geofencing calculated field zone group configuration is not configured for zone group: " + zoneGroupName);
}
}
// Check required fields: latitude and longitude
for (String requiredKey : requiredKeys) {
if (!arguments.containsKey(requiredKey)) {
throw new IllegalArgumentException("Missing required argument: " + requiredKey);
if (config == null) {
throw new IllegalArgumentException("Zone group configuration is not configured for zone group: " + zoneGroupName);
}
}
if (CollectionsUtil.isEmpty(config.getReportEvents())) {
throw new IllegalArgumentException("Zone group configuration report events must be specified for zone group: " + zoneGroupName);
}
String prefix = config.getReportTelemetryPrefix();
if (StringUtils.isBlank(prefix)) {
throw new IllegalArgumentException("Report telemetry prefix should be specified for zone group: " + zoneGroupName);
}
if (!usedPrefixes.add(prefix)) {
throw new IllegalArgumentException("Duplicate report telemetry prefix found: '" + prefix + "'. Must be unique!");
}
});
}
// 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();
private void validateCoordinateArguments() {
for (String coordinateKey : coordinateKeys) {
Argument argument = arguments.get(coordinateKey);
if (argument == null) {
throw new IllegalArgumentException("Missing required argument: " + argumentKey);
throw new IllegalArgumentException("Missing required coordinates argument: " + coordinateKey);
}
ReferencedEntityKey refEntityKey = argument.getRefEntityKey();
if (refEntityKey == null || refEntityKey.getType() == null) {
throw new IllegalArgumentException("Missing or invalid reference entity key for argument: " + argumentKey);
ReferencedEntityKey refEntityKey = validateAndGetRefEntityKey(argument, coordinateKey);
if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument '" + coordinateKey + "' must be of type TS_LATEST.");
}
switch (argumentKey) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!ArgumentType.TS_LATEST.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type TS_LATEST.");
}
if (argument.getRefDynamicSource() != null) {
throw new IllegalArgumentException("Dynamic source is not allowed for argument: '" + argumentKey + "'.");
}
}
case ALLOWED_ZONES_ARGUMENT_KEY,
RESTRICTED_ZONES_ARGUMENT_KEY -> {
if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type ATTRIBUTE.");
}
var dynamicSource = argument.getRefDynamicSource();
if (dynamicSource == null) {
continue;
}
if (!RELATION_QUERY.equals(dynamicSource)) {
throw new IllegalArgumentException("Only relation query dynamic source is supported for argument: '" + argumentKey + "'.");
}
if (argument.getRefDynamicSourceConfiguration() == null) {
throw new IllegalArgumentException("Missing dynamic source configuration for argument: '" + argumentKey + "'.");
}
argument.getRefDynamicSourceConfiguration().validate();
}
if (argument.getRefDynamicSource() != null) {
throw new IllegalArgumentException("Dynamic source is not allowed for argument: '" + coordinateKey + "'.");
}
}
}
private void validateZoneGroupAruguments(Map<String, Argument> zoneGroupsArguments) {
zoneGroupsArguments.forEach((argumentKey, argument) -> {
if (argument == null) {
throw new IllegalArgumentException("Zone group argument is not configured: " + argumentKey);
}
ReferencedEntityKey refEntityKey = validateAndGetRefEntityKey(argument, argumentKey);
if (!ArgumentType.ATTRIBUTE.equals(refEntityKey.getType())) {
throw new IllegalArgumentException("Argument '" + argumentKey + "' must be of type ATTRIBUTE.");
}
var dynamicSource = argument.getRefDynamicSource();
if (dynamicSource == null) {
return;
}
if (!RELATION_QUERY.equals(dynamicSource)) {
throw new IllegalArgumentException("Only relation query dynamic source is supported for argument: '" + argumentKey + "'.");
}
if (argument.getRefDynamicSourceConfiguration() == null) {
throw new IllegalArgumentException("Missing dynamic source configuration for argument: '" + argumentKey + "'.");
}
argument.getRefDynamicSourceConfiguration().validate();
});
}
private Map<String, Argument> getZoneGroupArguments() {
return arguments.entrySet()
.stream()
.filter(entry -> !coordinateKeys.contains(entry.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
private static 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);
}
return refEntityKey;
}
}

View File

@ -0,0 +1,49 @@
/**
* 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 lombok.Getter;
import java.util.Arrays;
@Getter
public enum GeofencingEvent {
ENTERED(0), LEFT(1), INSIDE(2), OUTSIDE(3);
private final int protoNumber; // Corresponds to GeofencingEvent
GeofencingEvent(int protoNumber) {
this.protoNumber = protoNumber;
}
private static final GeofencingEvent[] BY_PROTO;
static {
BY_PROTO = new GeofencingEvent[Arrays.stream(values()).mapToInt(GeofencingEvent::getProtoNumber).max().orElse(0) + 1];
for (var event : values()) {
BY_PROTO[event.getProtoNumber()] = event;
}
}
public static GeofencingEvent fromProtoNumber(int protoNumber) {
if (protoNumber < 0 || protoNumber >= BY_PROTO.length) {
throw new IllegalArgumentException("Invalid GeofencingEvent proto number " + protoNumber);
}
return BY_PROTO[protoNumber];
}
}

View File

@ -0,0 +1,28 @@
/**
* 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 lombok.Data;
import java.util.List;
@Data
public class GeofencingZoneGroupConfiguration {
private final String reportTelemetryPrefix;
private final List<GeofencingEvent> reportEvents;
}

View File

@ -908,9 +908,18 @@ message GeofencingZoneProto {
optional bool inside = 5;
}
enum GeofencingEventProto {
ENTERED = 0;
LEFT = 1;
INSIDE = 2;
OUTSIDE = 3;
}
message GeofencingArgumentProto {
string argName = 1; // e.g., "restrictedZones" or "allowedZones"
repeated GeofencingZoneProto zones = 2;
string argName = 1;
string telemetryPrefix = 2;
repeated GeofencingEventProto reportEvents = 3;
repeated GeofencingZoneProto zones = 4;
}
message CalculatedFieldStateProto {