From c783176e71ce886e094bbb54d648ea0e480bbe20 Mon Sep 17 00:00:00 2001 From: dshvaika Date: Thu, 7 Aug 2025 15:51:19 +0300 Subject: [PATCH] Updated to use zone groups --- ...faultCalculatedFieldProcessingService.java | 18 +- .../service/cf/ctx/state/ArgumentEntry.java | 5 +- .../cf/ctx/state/GeofencingArgumentEntry.java | 9 +- .../state/GeofencingCalculatedFieldState.java | 108 +++++++----- .../cf/ctx/state/GeofencingZoneState.java | 20 ++- .../server/utils/CalculatedFieldUtils.java | 23 ++- ...eofencingCalculatedFieldConfiguration.java | 159 ++++++++++-------- .../cf/configuration/GeofencingEvent.java | 49 ++++++ .../GeofencingZoneGroupConfiguration.java | 28 +++ common/proto/src/main/proto/queue.proto | 13 +- 10 files changed, 295 insertions(+), 137 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index d5e36cfc95..29c4809a75 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -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> 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 fetchGeofencingKvEntry(TenantId tenantId, List geofencingEntities, Argument argument) { + private ListenableFuture fetchGeofencingKvEntry(TenantId tenantId, List 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>> 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 ); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index c7f830431b..f76c6855a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -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 entityIdkvEntryMap) { - return new GeofencingArgumentEntry(entityIdkvEntryMap); + static ArgumentEntry createGeofencingValueArgument(Map entityIdkvEntryMap, GeofencingZoneGroupConfiguration zoneGroupConfiguration) { + return new GeofencingArgumentEntry(entityIdkvEntryMap, zoneGroupConfiguration); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java index cf77d5da7d..2acdf0be4c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java @@ -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 zoneStates; + private GeofencingZoneGroupConfiguration zoneGroupConfiguration; + private boolean forceResetPrevious; public GeofencingArgumentEntry() { } - public GeofencingArgumentEntry(Map entityIdKvEntryMap) { - this.zoneStates = toZones(entityIdKvEntryMap); + public GeofencingArgumentEntry(Map entityIdkvEntryMap, + GeofencingZoneGroupConfiguration zoneGroupConfiguration) { + this.zoneStates = toZones(entityIdkvEntryMap); + this.zoneGroupConfiguration = zoneGroupConfiguration; } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java index 960fdf217f..6d8a08914d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java @@ -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 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> 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 savedZonesStatesResults = updateGeofencingZonesState(ctx, entityCoordinates, false); - List restrictedZonesStatesResults = updateGeofencingZonesState(ctx, entityCoordinates, true); + ObjectNode resultNode = JacksonUtil.newObjectNode(); + getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { + var zoneGroupConfig = argumentEntry.getZoneGroupConfiguration(); + Set 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 allZoneStatesResults = - new ArrayList<>(savedZonesStatesResults.size() + restrictedZonesStatesResults.size()); - allZoneStatesResults.addAll(savedZonesStatesResults); - allZoneStatesResults.addAll(restrictedZonesStatesResults); + private Optional aggregateZoneGroupEvent(Set 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 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(); - 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 getGeofencingArguments() { + return arguments.entrySet() + .stream() + .filter(entry -> !coordinateKeys.contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue())); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneState.java index d4a42d0645..1b3879c828 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneState.java @@ -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; } } diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java index 370f7883f0..aaa68e1dd9 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -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 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 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; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java index b5482b3e96..78cb48c2ee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java @@ -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 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 requiredKeys = Set.of( + public static final Set coordinateKeys = Set.of( ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY ); + Map 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 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 zoneGroupsArguments) { + if (geofencingZoneGroupConfigurations == null) { + throw new IllegalArgumentException("Geofencing calculated field zone group configurations are empty!"); + } + Set 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 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 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 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; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingEvent.java new file mode 100644 index 0000000000..9cb51ea294 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingEvent.java @@ -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]; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java new file mode 100644 index 0000000000..c82151fc64 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingZoneGroupConfiguration.java @@ -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 reportEvents; + +} diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 87040fcf02..90332be104 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -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 {