Browse Source

Merge pull request #13857 from ShvaykaD/feature/geofencing-cf

Geofencing Calculated Field
pull/14033/head
Viacheslav Klimov 10 months ago
committed by GitHub
parent
commit
312f2ce95f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 32
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 35
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java
  3. 5
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
  4. 54
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  5. 3
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java
  6. 181
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  7. 37
      application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java
  8. 2
      application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java
  9. 1
      application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
  10. 2
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  11. 257
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  12. 2
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
  13. 4
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
  14. 4
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  15. 174
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  16. 10
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  17. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
  18. 16
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  19. 112
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  20. 26
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  21. 9
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
  22. 5
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  23. 103
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
  24. 207
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java
  25. 24
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.java
  26. 106
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java
  27. 13
      application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java
  28. 13
      application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
  29. 75
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  30. 52
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  31. 3
      application/src/main/resources/logback.xml
  32. 360
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  33. 26
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  34. 483
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
  35. 184
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java
  36. 169
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java
  37. 7
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
  38. 10
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  39. 8
      application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java
  40. 109
      application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java
  41. 3
      common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java
  42. 2
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  43. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  44. 5
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java
  45. 24
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java
  46. 23
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
  47. 7
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java
  48. 34
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  49. 39
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java
  50. 24
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ExpressionBasedCalculatedFieldConfiguration.java
  51. 86
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java
  52. 35
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java
  53. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java
  54. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java
  55. 57
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java
  56. 81
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java
  57. 19
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingEvent.java
  58. 25
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.java
  59. 24
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.java
  60. 20
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java
  61. 95
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java
  62. 4
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  63. 38
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java
  64. 216
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfigurationTest.java
  65. 54
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java
  66. 80
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java
  67. 161
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java
  68. 123
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
  69. 5
      common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java
  70. 12
      common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java
  71. 20
      common/proto/src/main/proto/queue.proto
  72. 22
      common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java
  73. 3
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java
  74. 43
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java
  75. 38
      common/util/src/main/java/org/thingsboard/common/util/geo/CirclePerimeterDefinition.java
  76. 35
      common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinition.java
  77. 48
      common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.java
  78. 49
      common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializer.java
  79. 35
      common/util/src/main/java/org/thingsboard/common/util/geo/PolygonPerimeterDefinition.java
  80. 50
      common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java
  81. 52
      common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializerTest.java
  82. 2
      dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java
  83. 77
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  84. 188
      dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java
  85. 19
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java
  86. 156
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java
  87. 3
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java
  88. 4
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
  89. 2
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java
  90. 2
      ui-ngx/src/app/core/auth/auth.models.ts
  91. 2
      ui-ngx/src/app/core/auth/auth.reducer.ts
  92. 32
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  93. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html
  94. 172
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  95. 102
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts
  96. 146
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html
  97. 76
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss
  98. 307
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts
  99. 2
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts
  100. 224
      ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html

32
application/src/main/data/upgrade/basic/schema_update.sql

@ -14,3 +14,35 @@
-- limitations under the License.
--
-- UPDATE TENANT PROFILE CONFIGURATION START
UPDATE tenant_profile
SET profile_data = jsonb_set(
profile_data,
'{configuration}',
(profile_data -> 'configuration')
|| jsonb_strip_nulls(
jsonb_build_object(
'minAllowedScheduledUpdateIntervalInSecForCF',
CASE
WHEN (profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(3600)
END,
'maxRelationLevelPerCfArgument',
CASE
WHEN (profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
THEN NULL
ELSE to_jsonb(10)
END
)
),
false
)
WHERE NOT (
(profile_data -> 'configuration') ? 'minAllowedScheduledUpdateIntervalInSecForCF'
AND
(profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
);
-- UPDATE TENANT PROFILE CONFIGURATION END

35
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldDynamicArgumentsRefreshMsg.java

@ -0,0 +1,35 @@
/**
* 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.actors.calculatedField;
import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
@Data
public class CalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg {
private final TenantId tenantId;
private final CalculatedFieldId cfId;
@Override
public MsgType getMsgType() {
return MsgType.CF_DYNAMIC_ARGUMENTS_REFRESH_MSG;
}
}

5
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java

@ -51,7 +51,7 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor {
@Override
public void destroy(TbActorStopReason stopReason, Throwable cause) throws TbActorException {
log.debug("[{}] Stopping CF entity actor.", processor.tenantId);
processor.stop();
processor.stop(false);
}
@Override
@ -75,6 +75,9 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor {
case CF_LINKED_TELEMETRY_MSG:
processor.process((EntityCalculatedFieldLinkedTelemetryMsg) msg);
break;
case CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG:
processor.process((EntityCalculatedFieldDynamicArgumentsRefreshMsg) msg);
break;
default:
return false;
}

54
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java

@ -48,6 +48,7 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import java.util.ArrayList;
import java.util.Collection;
@ -91,16 +92,18 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
this.ctx = ctx;
}
public void stop() {
log.info("[{}][{}] Stopping entity actor.", tenantId, entityId);
public void stop(boolean partitionChanged) {
log.info(partitionChanged ?
"[{}][{}] Stopping entity actor due to change partition event." :
"[{}][{}] Stopping entity actor.",
tenantId, entityId);
states.clear();
ctx.stop(ctx.getSelf());
}
public void process(CalculatedFieldPartitionChangeMsg msg) {
if (!systemContext.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, DataConstants.CF_QUEUE_NAME, tenantId, entityId).isMyPartition()) {
log.info("[{}] Stopping entity actor due to change partition event.", entityId);
ctx.stop(ctx.getSelf());
stop(true);
}
}
@ -224,6 +227,18 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
}
public void process(EntityCalculatedFieldDynamicArgumentsRefreshMsg msg) throws CalculatedFieldException {
log.debug("[{}][{}] Processing CF dynamic arguments refresh msg.", entityId, msg.getCfId());
CalculatedFieldState currentState = states.get(msg.getCfId());
if (currentState == null) {
log.debug("[{}][{}] Failed to find CF state for entity.", entityId, msg.getCfId());
} else {
currentState.setDirty(true);
log.debug("[{}][{}] CF state marked as dirty.", entityId, msg.getCfId());
}
msg.getCallback().onSuccess();
}
private void processTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List<CalculatedFieldId> cfIdList, MultipleTbCallback callback) throws CalculatedFieldException {
processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArguments(ctx, proto.getTsDataList()), toTbMsgId(proto), toTbMsgType(proto));
}
@ -251,6 +266,15 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state == null) {
state = getOrInitState(ctx);
justRestored = true;
} else if (state.isDirty()) {
log.debug("[{}][{}] Going to update dirty CF state.", entityId, ctx.getCfId());
try {
Map<String, ArgumentEntry> dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId);
dynamicArgsFromDb.forEach(newArgValues::putIfAbsent);
state.setDirty(false);
} catch (Exception e) {
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
}
}
if (state.isSizeOk()) {
if (state.updateState(ctx, newArgValues) || justRestored) {
@ -271,7 +295,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state != null) {
return state;
} else {
ListenableFuture<CalculatedFieldState> stateFuture = systemContext.getCalculatedFieldProcessingService().fetchStateFromDb(ctx, entityId);
ListenableFuture<CalculatedFieldState> stateFuture = cfService.fetchStateFromDb(ctx, entityId);
// Ugly but necessary. We do not expect to often fetch data from DB. Only once per <Entity, CalculatedField> pair lifetime.
// This call happens while processing the CF pack from the queue consumer. So the timeout should be relatively low.
// Alternatively, we can fetch the state outside the actor system and push separate command to create this actor,
@ -288,7 +312,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
boolean stateSizeChecked = false;
try {
if (ctx.isInitialized() && state.isReady()) {
CalculatedFieldResult calculationResult = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
CalculatedFieldResult calculationResult = state.performCalculation(entityId, ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
state.checkStateSize(ctxId, ctx.getMaxStateSize());
stateSizeChecked = true;
if (state.isSizeOk()) {
@ -298,7 +322,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
callback.onSuccess();
}
if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) {
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.getResult().toString(), null);
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.toStringOrElseNull(), null);
}
}
} else {
@ -367,7 +391,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
return mapToArguments(ctx.getMainEntityArguments(), scope, attrDataList);
return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, attrDataList);
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
@ -375,17 +399,23 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
return mapToArguments(argNames, scope, attrDataList);
List<String> geofencingArgumentNames = ctx.getLinkedEntityGeofencingArgumentNames();
return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList);
}
private Map<String, ArgumentEntry> mapToArguments(Map<ReferencedEntityKey, String> argNames, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
private Map<String, ArgumentEntry> mapToArguments(EntityId entityId, Map<ReferencedEntityKey, String> argNames, List<String> geoArgNames, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
Map<String, ArgumentEntry> arguments = new HashMap<>();
for (AttributeValueProto item : attrDataList) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
String argName = argNames.get(key);
if (argName != null) {
arguments.put(argName, new SingleValueArgumentEntry(item));
if (argName == null) {
continue;
}
if (geoArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry(entityId, item));
continue;
}
arguments.put(argName, new SingleValueArgumentEntry(item));
}
return arguments;
}

3
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerActor.java

@ -79,6 +79,9 @@ public class CalculatedFieldManagerActor extends AbstractCalculatedFieldActor {
case CF_LINKED_TELEMETRY_MSG:
processor.onLinkedTelemetryMsg((CalculatedFieldLinkedTelemetryMsg) msg);
break;
case CF_DYNAMIC_ARGUMENTS_REFRESH_MSG:
processor.onDynamicArgumentsRefreshMsg((CalculatedFieldDynamicArgumentsRefreshMsg) msg);
break;
default:
return false;
}

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

@ -27,6 +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.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
@ -56,7 +57,11 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
@ -69,6 +74,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
private final Map<CalculatedFieldId, CalculatedFieldCtx> calculatedFields = new HashMap<>();
private final Map<EntityId, List<CalculatedFieldCtx>> entityIdCalculatedFields = new HashMap<>();
private final Map<EntityId, List<CalculatedFieldLink>> entityIdCalculatedFieldLinks = new HashMap<>();
private final Map<CalculatedFieldId, ScheduledFuture<?>> cfDynamicArgumentsRefreshTasks = new ConcurrentHashMap<>();
private final CalculatedFieldProcessingService cfExecService;
private final CalculatedFieldStateService cfStateService;
@ -107,6 +113,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
calculatedFields.clear();
entityIdCalculatedFields.clear();
entityIdCalculatedFieldLinks.clear();
cfDynamicArgumentsRefreshTasks.values().forEach(future -> future.cancel(true));
cfDynamicArgumentsRefreshTasks.clear();
ctx.stop(ctx.getSelf());
}
@ -255,7 +263,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId);
callback.onSuccess();
} else {
var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
var cfCtx = getCfCtx(cf);
try {
cfCtx.init();
} catch (Exception e) {
@ -266,11 +274,16 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
// Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
addLinks(cf);
initCf(cfCtx, callback, false);
scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx);
applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> initCfForEntity(id, cfCtx, false, cb));
}
}
}
private CalculatedFieldCtx getCfCtx(CalculatedField cf) {
return new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService());
}
private void onCfUpdated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException {
var cfId = new CalculatedFieldId(msg.getEntityId().getId());
var oldCfCtx = calculatedFields.get(cfId);
@ -282,7 +295,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId);
callback.onSuccess();
} else {
var newCfCtx = new CalculatedFieldCtx(newCf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
var newCfCtx = getCfCtx(newCf);
try {
newCfCtx.init();
} catch (Exception e) {
@ -290,6 +303,13 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
calculatedFields.put(newCf.getId(), newCfCtx);
List<CalculatedFieldCtx> oldCfList = entityIdCalculatedFields.get(newCf.getEntityId());
boolean hasSchedulingConfigChanges = newCfCtx.hasSchedulingConfigChanges(oldCfCtx);
if (hasSchedulingConfigChanges) {
cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, false);
scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(newCfCtx);
}
List<CalculatedFieldCtx> newCfList = new CopyOnWriteArrayList<>();
boolean found = false;
for (CalculatedFieldCtx oldCtx : oldCfList) {
@ -312,7 +332,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
// Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
var stateChanges = newCfCtx.hasStateChanges(oldCfCtx);
if (stateChanges || newCfCtx.hasOtherSignificantChanges(oldCfCtx)) {
initCf(newCfCtx, callback, stateChanges);
applyToTargetCfEntityActors(newCfCtx, callback, (id, cb) -> initCfForEntity(id, newCfCtx, stateChanges, cb));
} else {
callback.onSuccess();
}
@ -326,30 +346,20 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
if (cfCtx == null) {
log.debug("[{}] CF was already deleted [{}]", tenantId, cfId);
callback.onSuccess();
} else {
entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx);
deleteLinks(cfCtx);
EntityId entityId = cfCtx.getEntityId();
EntityType entityType = cfCtx.getEntityId().getEntityType();
if (isProfileEntity(entityType)) {
var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId);
if (!entityIds.isEmpty()) {
//TODO: no need to do this if we cache all created actors and know which one belong to us;
var multiCallback = new MultipleTbCallback(entityIds.size(), callback);
entityIds.forEach(id -> {
if (isMyPartition(id, multiCallback)) {
deleteCfForEntity(id, cfId, multiCallback);
}
});
} else {
callback.onSuccess();
}
} else {
if (isMyPartition(entityId, callback)) {
deleteCfForEntity(entityId, cfId, callback);
}
}
return;
}
entityIdCalculatedFields.get(cfCtx.getEntityId()).remove(cfCtx);
deleteLinks(cfCtx);
cancelCfDynamicArgumentsRefreshTaskIfExists(cfId, true);
applyToTargetCfEntityActors(cfCtx, callback, (id, cb) -> deleteCfForEntity(id, cfId, cb));
}
private void cancelCfDynamicArgumentsRefreshTaskIfExists(CalculatedFieldId cfId, boolean cfDeleted) {
var existingTask = cfDynamicArgumentsRefreshTasks.remove(cfId);
if (existingTask != null) {
existingTask.cancel(false);
String reason = cfDeleted ? "deletion" : "update";
log.debug("[{}][{}] Cancelled dynamic arguments refresh task due to CF {}!", tenantId, cfId, reason);
}
}
@ -389,31 +399,11 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
for (var linkProto : linksList) {
var link = fromProto(linkProto);
var targetEntityId = link.entityId();
var targetEntityType = targetEntityId.getEntityType();
var cf = calculatedFields.get(link.cfId());
if (EntityType.DEVICE_PROFILE.equals(targetEntityType) || EntityType.ASSET_PROFILE.equals(targetEntityType)) {
// iterate over all entities that belong to profile and push the message for corresponding CF
var entityIds = entityProfileCache.getEntityIdsByProfileId(targetEntityId);
if (!entityIds.isEmpty()) {
MultipleTbCallback multipleCallback = new MultipleTbCallback(entityIds.size(), callback);
var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, multipleCallback);
entityIds.forEach(entityId -> {
if (isMyPartition(entityId, multipleCallback)) {
log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId);
getOrCreateActor(entityId).tell(newMsg);
}
});
} else {
callback.onSuccess();
}
} else {
if (isMyPartition(targetEntityId, callback)) {
log.debug("Pushing linked telemetry msg to specific actor [{}]", targetEntityId);
var newMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, callback);
getOrCreateActor(targetEntityId).tell(newMsg);
}
}
withTargetEntities(link.entityId(), callback, (ids, cb) -> {
var linkedTelemetryMsg = new EntityCalculatedFieldLinkedTelemetryMsg(tenantId, sourceEntityId, proto.getMsg(), cf, cb);
ids.forEach(id -> linkedTelemetryMsgForEntity(id, linkedTelemetryMsg));
});
}
}
@ -452,26 +442,46 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
return result;
}
private void initCf(CalculatedFieldCtx cfCtx, TbCallback callback, boolean forceStateReinit) {
EntityId entityId = cfCtx.getEntityId();
EntityType entityType = cfCtx.getEntityId().getEntityType();
if (isProfileEntity(entityType)) {
var entityIds = entityProfileCache.getEntityIdsByProfileId(entityId);
if (!entityIds.isEmpty()) {
var multiCallback = new MultipleTbCallback(entityIds.size(), callback);
entityIds.forEach(id -> {
if (isMyPartition(id, multiCallback)) {
initCfForEntity(id, cfCtx, forceStateReinit, multiCallback);
}
});
} else {
callback.onSuccess();
}
} else {
if (isMyPartition(entityId, callback)) {
initCfForEntity(entityId, cfCtx, forceStateReinit, callback);
}
private void scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(CalculatedFieldCtx cfCtx) {
CalculatedField cf = cfCtx.getCalculatedField();
if (!(cf.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledCfConfig)) {
return;
}
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(scheduledCfConfig.getScheduledUpdateInterval());
var scheduledMsg = new CalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfCtx.getCfId());
ScheduledFuture<?> scheduledFuture = systemContext
.schedulePeriodicMsgWithDelay(ctx, scheduledMsg, refreshDynamicSourceInterval, refreshDynamicSourceInterval);
cfDynamicArgumentsRefreshTasks.put(cf.getId(), scheduledFuture);
log.debug("[{}][{}] Scheduled dynamic arguments refresh task for CF!", tenantId, cf.getId());
}
public void onDynamicArgumentsRefreshMsg(CalculatedFieldDynamicArgumentsRefreshMsg msg) {
log.debug("[{}] [{}] Processing CF dynamic arguments refresh task.", tenantId, msg.getCfId());
CalculatedFieldCtx cfCtx = calculatedFields.get(msg.getCfId());
if (cfCtx == null) {
log.debug("[{}][{}] Failed to find CF context, going to stop dynamic arguments refresh task for CF.", tenantId, msg.getCfId());
cancelCfDynamicArgumentsRefreshTaskIfExists(msg.getCfId(), true);
return;
}
applyToTargetCfEntityActors(cfCtx, msg.getCallback(), (id, cb) -> refreshDynamicArgumentsForEntity(id, msg.getCfId(), cb));
}
private void refreshDynamicArgumentsForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) {
log.debug("Pushing CF dynamic arguments refresh msg to specific actor [{}]", entityId);
getOrCreateActor(entityId).tell(new EntityCalculatedFieldDynamicArgumentsRefreshMsg(tenantId, cfId, callback));
}
private void linkedTelemetryMsgForEntity(EntityId entityId, EntityCalculatedFieldLinkedTelemetryMsg msg) {
log.debug("Pushing linked telemetry msg to specific actor [{}]", entityId);
getOrCreateActor(entityId).tell(msg);
}
private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) {
@ -545,7 +555,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
private void initCalculatedField(CalculatedField cf) throws CalculatedFieldException {
var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService());
var cfCtx = new CalculatedFieldCtx(cf, systemContext.getTbelInvokeService(), systemContext.getApiLimitService(), systemContext.getRelationService());
try {
cfCtx.init();
} catch (Exception e) {
@ -555,6 +565,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
// We use copy on write lists to safely pass the reference to another actor for the iteration.
// Alternative approach would be to use any list but avoid modifications to the list (change the complete map value instead)
entityIdCalculatedFields.computeIfAbsent(cf.getEntityId(), id -> new CopyOnWriteArrayList<>()).add(cfCtx);
scheduleDynamicArgumentsRefreshTaskForCfIfNeeded(cfCtx);
}
private void initCalculatedFieldLink(CalculatedFieldLink link) {
@ -584,4 +595,30 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
}
private void applyToTargetCfEntityActors(CalculatedFieldCtx ctx,
TbCallback callback,
BiConsumer<EntityId, TbCallback> action) {
withTargetEntities(ctx.getEntityId(), callback, (ids, cb) -> ids.forEach(id -> action.accept(id, cb)));
}
private void withTargetEntities(EntityId entityId, TbCallback parentCallback, BiConsumer<List<EntityId>, TbCallback> consumer) {
if (isProfileEntity(entityId.getEntityType())) {
var ids = entityProfileCache.getEntityIdsByProfileId(entityId);
if (ids.isEmpty()) {
parentCallback.onSuccess();
return;
}
var multiCallback = new MultipleTbCallback(ids.size(), parentCallback);
var profileEntityIds = ids.stream().filter(id -> isMyPartition(id, multiCallback)).toList();
if (profileEntityIds.isEmpty()) {
return;
}
consumer.accept(profileEntityIds, multiCallback);
return;
}
if (isMyPartition(entityId, parentCallback)) {
consumer.accept(List.of(entityId), parentCallback);
}
}
}

37
application/src/main/java/org/thingsboard/server/actors/calculatedField/EntityCalculatedFieldDynamicArgumentsRefreshMsg.java

@ -0,0 +1,37 @@
/**
* 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.actors.calculatedField;
import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.common.msg.queue.TbCallback;
@Data
public class EntityCalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg {
private final TenantId tenantId;
private final CalculatedFieldId cfId;
private final TbCallback callback;
@Override
public MsgType getMsgType() {
return MsgType.CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG;
}
}

2
application/src/main/java/org/thingsboard/server/actors/calculatedField/MultipleTbCallback.java

@ -50,7 +50,7 @@ public class MultipleTbCallback implements TbCallback {
@Override
public void onFailure(Throwable t) {
log.warn("[{}][{}] onFailure.", id, callback.getId());
log.warn("[{}][{}] onFailure.", id, callback.getId(), t);
callback.onFailure(t);
}
}

1
application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java

@ -289,7 +289,6 @@ public class CalculatedFieldController extends BaseController {
default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities.");
}
}
}
}

2
application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java

@ -162,6 +162,8 @@ public class SystemInfoController extends BaseController {
}
systemParams.setMaxArgumentsPerCF(tenantProfileConfiguration.getMaxArgumentsPerCF());
systemParams.setMaxDataPointsPerRollingArg(tenantProfileConfiguration.getMaxDataPointsPerRollingArg());
systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF());
systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument());
systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId()));
}
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))

257
application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java

@ -0,0 +1,257 @@
/**
* 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.service.cf;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardExecutors;
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.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
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.CalculatedFieldArgumentUtils.createDefaultKvEntry;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument;
@Data
@Slf4j
public abstract class AbstractCalculatedFieldProcessingService {
protected final AttributesService attributesService;
protected final TimeseriesService timeseriesService;
protected final ApiLimitService apiLimitService;
protected final RelationService relationService;
protected ListeningExecutorService calculatedFieldCallbackExecutor;
@PostConstruct
public void init() {
calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(
Math.max(4, Runtime.getRuntime().availableProcessors()), getExecutorNamePrefix()));
}
@PreDestroy
public void stop() {
if (calculatedFieldCallbackExecutor != null) {
calculatedFieldCallbackExecutor.shutdownNow();
}
}
protected abstract String getExecutorNamePrefix();
public ListenableFuture<CalculatedFieldState> fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCalculatedField().getType()) {
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false);
case SIMPLE, SCRIPT -> {
Map<String, ListenableFuture<ArgumentEntry>> futures = new HashMap<>();
for (var entry : ctx.getArguments().entrySet()) {
var argEntityId = resolveEntityId(entityId, entry.getValue());
var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), System.currentTimeMillis());
futures.put(entry.getKey(), argValueFuture);
}
yield futures;
}
};
return Futures.whenAllComplete(argFutures.values()).call(() -> {
var result = createStateByType(ctx);
result.updateState(ctx, resolveArgumentFutures(argFutures));
return result;
}, MoreExecutors.directExecutor());
}
protected EntityId resolveEntityId(EntityId entityId, Argument argument) {
return argument.getRefEntityId() != null ? argument.getRefEntityId() : entityId;
}
protected Map<String, ArgumentEntry> resolveArgumentFutures(Map<String, ListenableFuture<ArgumentEntry>> argFutures) {
return argFutures.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey, // Keep the key as is
entry -> {
try {
return entry.getValue().get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException("Failed to fetch " + entry.getKey() + ": " + cause.getMessage(), cause);
} catch (InterruptedException e) {
throw new RuntimeException("Failed to fetch" + entry.getKey(), e);
}
}
));
}
protected Map<String, ListenableFuture<ArgumentEntry>> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>();
Set<Map.Entry<String, Argument>> entries = ctx.getArguments().entrySet();
if (dynamicArgumentsOnly) {
entries = entries.stream()
.filter(entry -> entry.getValue().hasDynamicSource())
.collect(Collectors.toSet());
}
for (var entry : entries) {
switch (entry.getKey()) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY ->
argFutures.put(entry.getKey(), fetchArgumentValue(ctx.getTenantId(), entityId, entry.getValue(), System.currentTimeMillis()));
default -> {
var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry);
argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds ->
fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue()), MoreExecutors.directExecutor()));
}
}
}
return argFutures;
}
private ListenableFuture<List<EntityId>> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Map.Entry<String, Argument> entry) {
Argument value = entry.getValue();
if (value.getRefEntityId() != null) {
return Futures.immediateFuture(List.of(value.getRefEntityId()));
}
if (!value.hasDynamicSource()) {
return Futures.immediateFuture(List.of(entityId));
}
var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration();
return switch (refDynamicSourceConfiguration.getType()) {
case RELATION_QUERY -> {
var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration;
if (configuration.isSimpleRelation()) {
yield switch (configuration.getDirection()) {
case FROM ->
Futures.transform(relationService.findByFromAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
case TO ->
Futures.transform(relationService.findByToAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
};
}
yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)),
configuration::resolveEntityIds, calculatedFieldCallbackExecutor);
}
};
}
private ListenableFuture<ArgumentEntry> fetchGeofencingKvEntry(TenantId tenantId, List<EntityId> geofencingEntities, Argument argument) {
if (argument.getRefEntityKey().getType() != ArgumentType.ATTRIBUTE) {
throw new IllegalStateException("Unsupported argument key type: " + argument.getRefEntityKey().getType());
}
List<ListenableFuture<Map.Entry<EntityId, AttributeKvEntry>>> kvFutures = geofencingEntities.stream()
.map(entityId -> {
var attributesFuture = attributesService.find(
tenantId,
entityId,
argument.getRefEntityKey().getScope(),
argument.getRefEntityKey().getKey()
);
return Futures.transform(attributesFuture, resultOpt ->
Map.entry(entityId, resultOpt.orElseGet(() ->
new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))),
calculatedFieldCallbackExecutor
);
}).collect(Collectors.toList());
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))), MoreExecutors.directExecutor());
}
protected ListenableFuture<ArgumentEntry> fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) {
return switch (argument.getRefEntityKey().getType()) {
case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs);
case ATTRIBUTE -> fetchAttribute(tenantId, entityId, argument, startTs);
case TS_LATEST -> fetchTsLatest(tenantId, entityId, argument, startTs);
};
}
private ListenableFuture<ArgumentEntry> fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument, long queryEndTs) {
long argTimeWindow = argument.getTimeWindow() == 0 ? queryEndTs : argument.getTimeWindow();
long startInterval = queryEndTs - argTimeWindow;
ReadTsKvQuery query = buildTsRollingQuery(tenantId, argument, startInterval, queryEndTs);
log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query);
ListenableFuture<List<TsKvEntry>> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
return Futures.transform(tsRollingFuture, tsRolling -> {
log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query);
return ArgumentEntry.createTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow);
}, calculatedFieldCallbackExecutor);
}
private ListenableFuture<ArgumentEntry> fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) {
log.trace("[{}][{}] Fetching attribute for key {}", tenantId, entityId, argument.getRefEntityKey());
var attributeOptFuture = attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey());
return Futures.transform(attributeOptFuture, attrOpt -> {
log.debug("[{}][{}] Fetched attribute for key {}: {}", tenantId, entityId, argument.getRefEntityKey(), attrOpt);
AttributeKvEntry attributeKvEntry = attrOpt.orElseGet(() -> new BaseAttributeKvEntry(createDefaultKvEntry(argument), defaultLastUpdateTs, 0L));
return transformSingleValueArgument(Optional.of(attributeKvEntry));
}, calculatedFieldCallbackExecutor);
}
protected ListenableFuture<ArgumentEntry> fetchTsLatest(TenantId tenantId, EntityId entityId, Argument argument, long startTs) {
String timeseriesKey = argument.getRefEntityKey().getKey();
log.trace("[{}][{}] Fetching latest timeseries {}", tenantId, entityId, timeseriesKey);
return transformSingleValueArgument(
Futures.transform(
timeseriesService.findLatest(tenantId, entityId, timeseriesKey),
result -> {
log.debug("[{}][{}] Fetched latest timeseries {}: {}", tenantId, entityId, timeseriesKey, result);
return result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L)));
}, calculatedFieldCallbackExecutor));
}
private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) {
long maxDataPoints = apiLimitService.getLimit(
tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
int argumentLimit = argument.getLimit();
int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argumentLimit;
return new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, endTs, 0, limit, Aggregation.NONE);
}
}

2
application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java

@ -34,6 +34,8 @@ public interface CalculatedFieldProcessingService {
ListenableFuture<CalculatedFieldState> fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId);
Map<String, ArgumentEntry> fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId);
Map<String, ArgumentEntry> fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map<String, Argument> arguments);
void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult calculationResult, List<CalculatedFieldId> cfIds, TbCallback callback);

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

@ -34,4 +34,8 @@ public final class CalculatedFieldResult {
(result.isTextual() && result.asText().isEmpty());
}
public String toStringOrElseNull() {
return result == null ? null : result.toString();
}
}

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

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
@ -52,6 +53,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
private final CalculatedFieldService calculatedFieldService;
private final TbelInvokeService tbelInvokeService;
private final ApiLimitService apiLimitService;
private final RelationService relationService;
private final ConcurrentMap<CalculatedFieldId, CalculatedField> calculatedFields = new ConcurrentHashMap<>();
private final ConcurrentMap<EntityId, List<CalculatedField>> entityIdCalculatedFields = new ConcurrentHashMap<>();
@ -111,7 +113,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
if (ctx == null) {
CalculatedField calculatedField = getCalculatedField(calculatedFieldId);
if (calculatedField != null) {
ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService);
ctx = new CalculatedFieldCtx(calculatedField, tbelInvokeService, apiLimitService, relationService);
calculatedFieldsCtx.put(calculatedFieldId, ctx);
log.debug("[{}] Put calculated field ctx into cache: {}", calculatedFieldId, ctx);
}

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

@ -15,46 +15,28 @@
*/
package org.thingsboard.server.service.cf;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.math.NumberUtils;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg;
import org.thingsboard.server.actors.calculatedField.MultipleTbCallback;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType;
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.OutputType;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
@ -70,20 +52,12 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.SCOPE;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@ -91,76 +65,53 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent
@Service
@Slf4j
@RequiredArgsConstructor
public class DefaultCalculatedFieldProcessingService implements CalculatedFieldProcessingService {
public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedFieldProcessingService implements CalculatedFieldProcessingService {
private final AttributesService attributesService;
private final TimeseriesService timeseriesService;
private final TbClusterService clusterService;
private final ApiLimitService apiLimitService;
private final PartitionService partitionService;
private ListeningExecutorService calculatedFieldCallbackExecutor;
@PostConstruct
public void init() {
calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool(
Math.max(4, Runtime.getRuntime().availableProcessors()), "calculated-field-callback"));
public DefaultCalculatedFieldProcessingService(AttributesService attributesService,
TimeseriesService timeseriesService,
ApiLimitService apiLimitService,
RelationService relationService,
TbClusterService clusterService,
PartitionService partitionService) {
super(attributesService, timeseriesService, apiLimitService, relationService);
this.clusterService = clusterService;
this.partitionService = partitionService;
}
@PreDestroy
public void stop() {
if (calculatedFieldCallbackExecutor != null) {
calculatedFieldCallbackExecutor.shutdownNow();
}
@Override
protected String getExecutorNamePrefix() {
return "calculated-field-callback";
}
@Override
public ListenableFuture<CalculatedFieldState> fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>();
for (var entry : ctx.getArguments().entrySet()) {
var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue());
argFutures.put(entry.getKey(), argValueFuture);
return super.fetchStateFromDb(ctx, entityId);
}
@Override
public Map<String, ArgumentEntry> fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
// only geofencing calculated fields supports dynamic arguments scheduled updates
if (!ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) {
return Map.of();
}
return Futures.whenAllComplete(argFutures.values()).call(() -> {
var result = createStateByType(ctx);
result.updateState(ctx, argFutures.entrySet().stream()
.collect(Collectors.toMap(
Entry::getKey, // Keep the key as is
entry -> {
try {
// Resolve the future to get the value
return entry.getValue().get();
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e);
}
}
)));
return result;
}, calculatedFieldCallbackExecutor);
return resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true));
}
@Override
public Map<String, ArgumentEntry> fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map<String, Argument> arguments) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>();
for (var entry : arguments.entrySet()) {
var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
var argValueFuture = fetchKvEntry(tenantId, argEntityId, entry.getValue());
if (entry.getValue().hasDynamicSource()) {
continue;
}
var argEntityId = resolveEntityId(entityId, entry.getValue());
var argValueFuture = fetchArgumentValue(tenantId, argEntityId, entry.getValue(), System.currentTimeMillis());
argFutures.put(entry.getKey(), argValueFuture);
}
return argFutures.entrySet().stream()
.collect(Collectors.toMap(
Entry::getKey, // Keep the key as is
entry -> {
try {
// Resolve the future to get the value
return entry.getValue().get();
} catch (ExecutionException | InterruptedException e) {
throw new RuntimeException("Error getting future result for key: " + entry.getKey(), e);
}
}
));
return resolveArgumentFutures(argFutures);
}
@Override
@ -169,7 +120,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
OutputType type = calculatedFieldResult.getType();
TbMsgType msgType = OutputType.ATTRIBUTES.equals(type) ? TbMsgType.POST_ATTRIBUTES_REQUEST : TbMsgType.POST_TELEMETRY_REQUEST;
TbMsgMetaData md = OutputType.ATTRIBUTES.equals(type) ? new TbMsgMetaData(Map.of(SCOPE, calculatedFieldResult.getScope().name())) : TbMsgMetaData.EMPTY;
TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(calculatedFieldResult.getResult().toString()).build();
TbMsg msg = TbMsg.newMsg().type(msgType).originator(entityId).previousCalculatedFieldIds(cfIds).metaData(md).data(calculatedFieldResult.toStringOrElseNull()).build();
clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
@ -241,69 +192,6 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
return builder.build();
}
private ListenableFuture<ArgumentEntry> fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) {
return switch (argument.getRefEntityKey().getType()) {
case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument);
case ATTRIBUTE -> transformSingleValueArgument(
Futures.transform(
attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()),
result -> result.or(() -> Optional.of(new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))),
calculatedFieldCallbackExecutor)
);
case TS_LATEST -> transformSingleValueArgument(
Futures.transform(
timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()),
result -> result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))),
calculatedFieldCallbackExecutor));
};
}
private ListenableFuture<ArgumentEntry> transformSingleValueArgument(ListenableFuture<Optional<? extends KvEntry>> kvEntryFuture) {
return Futures.transform(kvEntryFuture, kvEntry -> {
if (kvEntry.isPresent() && kvEntry.get().getValue() != null) {
return ArgumentEntry.createSingleValueArgument(kvEntry.get());
} else {
return new SingleValueArgumentEntry();
}
}, calculatedFieldCallbackExecutor);
}
private ListenableFuture<ArgumentEntry> fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument) {
long currentTime = System.currentTimeMillis();
long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow();
long startTs = currentTime - timeWindow;
long maxDataPoints = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
int argumentLimit = argument.getLimit();
int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argument.getLimit();
ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, currentTime, 0, limit, Aggregation.NONE);
ListenableFuture<List<TsKvEntry>> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query));
return Futures.transform(tsRollingFuture, tsRolling -> tsRolling == null ? new TsRollingArgumentEntry(limit, timeWindow) : ArgumentEntry.createTsRollingArgument(tsRolling, limit, timeWindow), calculatedFieldCallbackExecutor);
}
private KvEntry createDefaultKvEntry(Argument argument) {
String key = argument.getRefEntityKey().getKey();
String defaultValue = argument.getDefaultValue();
if (StringUtils.isBlank(defaultValue)) {
return new StringDataEntry(key, null);
}
if (NumberUtils.isParsable(defaultValue)) {
return new DoubleDataEntry(key, Double.parseDouble(defaultValue));
}
if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) {
return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue));
}
return new StringDataEntry(key, defaultValue);
}
private CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) {
return switch (ctx.getCfType()) {
case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames());
case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames());
};
}
private static class TbCallbackWrapper implements TbQueueCallback {
private final TbCallback callback;

10
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java

@ -19,10 +19,13 @@ 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.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import java.util.List;
import java.util.Map;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@ -31,7 +34,8 @@ import java.util.List;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"),
@JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING")
@JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"),
@JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING")
})
public interface ArgumentEntry {
@ -58,4 +62,8 @@ public interface ArgumentEntry {
return new TsRollingArgumentEntry(kvEntries, limit, timeWindow);
}
static ArgumentEntry createGeofencingValueArgument(Map<EntityId, KvEntry> entityIdkvEntryMap) {
return new GeofencingArgumentEntry(entityIdkvEntryMap);
}
}

2
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java

@ -16,5 +16,5 @@
package org.thingsboard.server.service.cf.ctx.state;
public enum ArgumentEntryType {
SINGLE_VALUE, TS_ROLLING
SINGLE_VALUE, TS_ROLLING, GEOFENCING
}

16
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java

@ -25,8 +25,6 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto;
@Data
@AllArgsConstructor
public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
@ -95,19 +93,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState {
}
}
@Override
public void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof TsRollingArgumentEntry) {
return;
}
if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) {
throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation.");
}
}
}
protected abstract void validateNewEntry(ArgumentEntry newEntry);
protected void validateNewEntry(ArgumentEntry newEntry) {}
private void updateLastUpdateTimestamp(ArgumentEntry entry) {
long newTs = this.latestTimestamp;

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

@ -25,10 +25,13 @@ 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.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.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -36,6 +39,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
@ -64,6 +68,7 @@ public class CalculatedFieldCtx {
private String expression;
private boolean useLatestTs;
private TbelInvokeService tbelInvokeService;
private RelationService relationService;
private CalculatedFieldScriptEngine calculatedFieldScriptEngine;
private ThreadLocal<Expression> customExpression;
@ -73,31 +78,56 @@ public class CalculatedFieldCtx {
private long maxStateSize;
private long maxSingleValueArgumentSize;
public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService) {
private List<String> mainEntityGeofencingArgumentNames;
private List<String> linkedEntityGeofencingArgumentNames;
public CalculatedFieldCtx(CalculatedField calculatedField, TbelInvokeService tbelInvokeService, ApiLimitService apiLimitService, RelationService relationService) {
this.calculatedField = calculatedField;
this.cfId = calculatedField.getId();
this.tenantId = calculatedField.getTenantId();
this.entityId = calculatedField.getEntityId();
this.cfType = calculatedField.getType();
CalculatedFieldConfiguration configuration = calculatedField.getConfiguration();
this.arguments = configuration.getArguments();
this.arguments = new HashMap<>();
this.mainEntityArguments = new HashMap<>();
this.linkedEntityArguments = new HashMap<>();
for (Map.Entry<String, Argument> entry : arguments.entrySet()) {
var refId = entry.getValue().getRefEntityId();
var refKey = entry.getValue().getRefEntityKey();
if (refId == null || refId.equals(calculatedField.getEntityId())) {
mainEntityArguments.put(refKey, entry.getKey());
} else {
linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
this.argNames = new ArrayList<>();
this.mainEntityGeofencingArgumentNames = new ArrayList<>();
this.linkedEntityGeofencingArgumentNames = new ArrayList<>();
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();
if (refId == null && entry.getValue().hasDynamicSource()) {
continue;
}
if (refId == null || refId.equals(calculatedField.getEntityId())) {
mainEntityArguments.put(refKey, entry.getKey());
} else {
linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
}
}
this.argNames.addAll(arguments.keySet());
if (argBasedConfig instanceof ExpressionBasedCalculatedFieldConfiguration expressionBasedConfig) {
this.expression = expressionBasedConfig.getExpression();
this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) argBasedConfig).isUseLatestTs();
}
if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration geofencingConfig) {
geofencingConfig.getZoneGroups().forEach((zoneGroupName, config) -> {
if (config.isCfEntitySource(entityId)) {
mainEntityGeofencingArgumentNames.add(zoneGroupName);
return;
}
if (config.isLinkedCfEntitySource(entityId)) {
linkedEntityGeofencingArgumentNames.add(zoneGroupName);
}
});
}
}
this.argNames = new ArrayList<>(arguments.keySet());
this.output = configuration.getOutput();
this.expression = configuration.getExpression();
this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) configuration).isUseLatestTs();
this.tbelInvokeService = tbelInvokeService;
this.relationService = relationService;
this.maxDataPointsPerRollingArg = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);
this.maxStateSize = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
@ -105,25 +135,29 @@ public class CalculatedFieldCtx {
}
public void init() {
if (CalculatedFieldType.SCRIPT.equals(cfType)) {
try {
this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService);
initialized = true;
} catch (Exception e) {
throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e);
switch (cfType) {
case SCRIPT -> {
try {
this.calculatedFieldScriptEngine = initEngine(tenantId, expression, tbelInvokeService);
initialized = true;
} catch (Exception e) {
throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e);
}
}
} else {
if (isValidExpression(expression)) {
this.customExpression = ThreadLocal.withInitial(() ->
new ExpressionBuilder(expression)
.functions(userDefinedFunctions)
.implicitMultiplication(true)
.variables(this.arguments.keySet())
.build()
);
initialized = true;
} else {
throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.");
case GEOFENCING -> initialized = true;
case SIMPLE -> {
if (isValidExpression(expression)) {
this.customExpression = ThreadLocal.withInitial(() ->
new ExpressionBuilder(expression)
.functions(userDefinedFunctions)
.implicitMultiplication(true)
.variables(this.arguments.keySet())
.build()
);
initialized = true;
} else {
throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.");
}
}
}
}
@ -293,7 +327,7 @@ public class CalculatedFieldCtx {
}
public boolean hasOtherSignificantChanges(CalculatedFieldCtx other) {
boolean expressionChanged = !expression.equals(other.expression);
boolean expressionChanged = calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression);
boolean outputChanged = !output.equals(other.output);
return expressionChanged || outputChanged;
}
@ -304,6 +338,16 @@ public class CalculatedFieldCtx {
return typeChanged || argumentsChanged;
}
public boolean hasSchedulingConfigChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration otherConfig) {
boolean refreshTriggerChanged = thisConfig.isScheduledUpdateEnabled() != otherConfig.isScheduledUpdateEnabled();
boolean refreshIntervalChanged = thisConfig.getScheduledUpdateInterval() != otherConfig.getScheduledUpdateInterval();
return refreshTriggerChanged || refreshIntervalChanged;
}
return false;
}
public String getSizeExceedsLimitMessage() {
return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!";
}

26
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java

@ -20,12 +20,17 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.List;
import java.util.Map;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArgumentProto;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
@ -34,6 +39,7 @@ import java.util.Map;
@JsonSubTypes({
@JsonSubTypes.Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"),
@JsonSubTypes.Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"),
@JsonSubTypes.Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
})
public interface CalculatedFieldState {
@ -44,11 +50,18 @@ public interface CalculatedFieldState {
long getLatestTimestamp();
default void setDirty(boolean dirty) {
}
default boolean isDirty() {
return false;
}
void setRequiredArguments(List<String> requiredArguments);
boolean updateState(CalculatedFieldCtx ctx, Map<String, ArgumentEntry> argumentValues);
ListenableFuture<CalculatedFieldResult> performCalculation(CalculatedFieldCtx ctx);
ListenableFuture<CalculatedFieldResult> performCalculation(EntityId entityId, CalculatedFieldCtx ctx);
@JsonIgnore
boolean isReady();
@ -62,6 +75,15 @@ public interface CalculatedFieldState {
void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize);
void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx);
default void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) {
if (entry instanceof TsRollingArgumentEntry || entry instanceof GeofencingArgumentEntry) {
return;
}
if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
if (ctx.getMaxSingleValueArgumentSize() > 0 && toSingleValueArgumentProto(name, singleValueArgumentEntry).getSerializedSize() > ctx.getMaxSingleValueArgumentSize()) {
throw new IllegalArgumentException("Single value size exceeds the maximum allowed limit. The argument will not be used for calculation.");
}
}
}
}

9
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java

@ -20,6 +20,7 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.script.api.tbel.TbelCfArg;
@ -27,6 +28,7 @@ import org.thingsboard.script.api.tbel.TbelCfCtx;
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import java.util.ArrayList;
@ -37,6 +39,7 @@ import java.util.Map;
@Data
@Slf4j
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
public ScriptCalculatedFieldState(List<String> requiredArguments) {
@ -49,11 +52,7 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
protected void validateNewEntry(ArgumentEntry newEntry) {
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(CalculatedFieldCtx ctx) {
public ListenableFuture<CalculatedFieldResult> performCalculation(EntityId entityId, CalculatedFieldCtx ctx) {
Map<String, TbelCfArg> arguments = new LinkedHashMap<>();
List<Object> args = new ArrayList<>(ctx.getArgNames().size() + 1);
args.add(new Object()); // first element is a ctx, but we will set it later;

5
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java

@ -20,11 +20,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BasicKvEntry;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
@ -33,6 +35,7 @@ import java.util.Map;
@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
public SimpleCalculatedFieldState(List<String> requiredArguments) {
@ -52,7 +55,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(CalculatedFieldCtx ctx) {
public ListenableFuture<CalculatedFieldResult> performCalculation(EntityId entityId, CalculatedFieldCtx ctx) {
var expr = ctx.getCustomExpression().get();
for (Map.Entry<String, ArgumentEntry> entry : this.arguments.entrySet()) {

103
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java

@ -0,0 +1,103 @@
/**
* 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.service.cf.ctx.state.geofencing;
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.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
import java.util.Map;
import java.util.stream.Collectors;
@Data
@Slf4j
public class GeofencingArgumentEntry implements ArgumentEntry {
private Map<EntityId, GeofencingZoneState> zoneStates;
private boolean forceResetPrevious;
public GeofencingArgumentEntry() {
}
public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) {
this.zoneStates = toZones(Map.of(entityId, ProtoUtils.fromProto(entry)));
}
public GeofencingArgumentEntry(Map<EntityId, KvEntry> entityIdkvEntryMap) {
this.zoneStates = toZones(entityIdkvEntryMap);
}
@Override
public ArgumentEntryType getType() {
return ArgumentEntryType.GEOFENCING;
}
@Override
public Object getValue() {
return zoneStates;
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType());
}
boolean updated = false;
for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) {
if (updateZone(zoneEntry)) {
updated = true;
}
}
return updated;
}
@Override
public boolean isEmpty() {
return zoneStates == null || zoneStates.isEmpty();
}
@Override
public TbelCfArg toTbelCfArg() {
return new TbelCfTsGeofencingArg(zoneStates);
}
private Map<EntityId, GeofencingZoneState> toZones(Map<EntityId, KvEntry> entityIdKvEntryMap) {
return entityIdKvEntryMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey,
entry -> new GeofencingZoneState(entry.getKey(), entry.getValue())));
}
private boolean updateZone(Map.Entry<EntityId, GeofencingZoneState> zoneEntry) {
EntityId zoneId = zoneEntry.getKey();
GeofencingZoneState newZoneState = zoneEntry.getValue();
GeofencingZoneState existingZoneState = zoneStates.get(zoneId);
if (existingZoneState == null) {
zoneStates.put(zoneId, newZoneState);
return true;
}
return existingZoneState.update(newZoneState);
}
}

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

@ -0,0 +1,207 @@
/**
* 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.service.cf.ctx.state.geofencing;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j;
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.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
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.geofencing.GeofencingPresenceStatus.INSIDE;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE;
@Data
@Slf4j
@EqualsAndHashCode(callSuper = true)
public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
private boolean dirty;
public GeofencingCalculatedFieldState() {
super(new ArrayList<>(), new HashMap<>(), false, -1);
this.dirty = false;
}
public GeofencingCalculatedFieldState(List<String> argNames) {
super(argNames);
}
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.GEOFENCING;
}
@Override
public boolean updateState(CalculatedFieldCtx ctx, Map<String, ArgumentEntry> argumentValues) {
if (arguments == null) {
arguments = new HashMap<>();
}
boolean stateUpdated = false;
for (var entry : argumentValues.entrySet()) {
String key = entry.getKey();
ArgumentEntry newEntry = entry.getValue();
checkArgumentSize(key, newEntry, ctx);
ArgumentEntry existingEntry = arguments.get(key);
boolean entryUpdated;
if (existingEntry == null || newEntry.isForceResetPrevious()) {
entryUpdated = switch (key) {
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> {
if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only SINGLE_VALUE type is allowed.");
}
arguments.put(key, singleValueArgumentEntry);
yield true;
}
default -> {
if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " +
"Only GEOFENCING type is allowed.");
}
arguments.put(key, geofencingArgumentEntry);
yield true;
}
};
} else {
entryUpdated = existingEntry.updateEntry(newEntry);
}
if (entryUpdated) {
stateUpdated = true;
}
}
return stateUpdated;
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(EntityId entityId, 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);
var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
Map<String, ZoneGroupConfiguration> zoneGroups = geofencingCfg.getZoneGroups();
ObjectNode resultNode = JacksonUtil.newObjectNode();
List<ListenableFuture<Boolean>> relationFutures = new ArrayList<>();
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> {
ZoneGroupConfiguration zoneGroupCfg = zoneGroups.get(argumentKey);
if (zoneGroupCfg == null) {
throw new RuntimeException("Zone group configuration is missing for the: " + entityId);
}
boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones();
List<GeofencingEvalResult> zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size());
argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> {
GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates);
zoneResults.add(eval);
if (createRelationsWithMatchedZones) {
GeofencingTransitionEvent transitionEvent = eval.transition();
if (transitionEvent == null) {
return;
}
EntityRelation relation = switch (zoneGroupCfg.getDirection()) {
case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType());
case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType());
};
ListenableFuture<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, zoneGroupCfg.getReportStrategy(), resultNode);
});
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode);
if (relationFutures.isEmpty()) {
return Futures.immediateFuture(result);
}
return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor());
}
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() {
return arguments.entrySet()
.stream()
.filter(entry -> entry.getValue().getType().equals(ArgumentEntryType.GEOFENCING))
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue()));
}
private void updateResultNode(String argumentKey, List<GeofencingEvalResult> zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) {
GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults);
final String eventKey = argumentKey + "Event";
final String statusKey = argumentKey + "Status";
switch (geofencingReportStrategy) {
case REPORT_TRANSITION_EVENTS_ONLY -> addTransitionEventIfExists(resultNode, aggregationResult, eventKey);
case REPORT_PRESENCE_STATUS_ONLY -> resultNode.put(statusKey, aggregationResult.status().name());
case REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS -> {
addTransitionEventIfExists(resultNode, aggregationResult, eventKey);
resultNode.put(statusKey, aggregationResult.status().name());
}
}
}
private GeofencingEvalResult aggregateZoneGroup(List<GeofencingEvalResult> zoneResults) {
boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status()));
boolean prevInside = zoneResults.stream()
.anyMatch(r -> GeofencingTransitionEvent.LEFT.equals(r.transition()) || r.transition() == null && r.status() == INSIDE);
GeofencingTransitionEvent transition = null;
if (!prevInside && nowInside) {
transition = GeofencingTransitionEvent.ENTERED;
} else if (prevInside && !nowInside) {
transition = GeofencingTransitionEvent.LEFT;
}
return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE);
}
private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) {
if (aggregationResult.transition() != null) {
resultNode.put(eventKey, aggregationResult.transition().name());
}
}
}

24
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingEvalResult.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.service.cf.ctx.state.geofencing;
import jakarta.annotation.Nullable;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
public record GeofencingEvalResult(@Nullable GeofencingTransitionEvent transition,
GeofencingPresenceStatus status) {
}

106
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingZoneState.java

@ -0,0 +1,106 @@
/**
* 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.service.cf.ctx.state.geofencing;
import lombok.Data;
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.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE;
@Data
public class GeofencingZoneState {
private final EntityId zoneId;
private long ts;
private Long version;
private PerimeterDefinition perimeterDefinition;
@EqualsAndHashCode.Exclude
private GeofencingPresenceStatus lastPresence;
public GeofencingZoneState(EntityId zoneId, KvEntry entry) {
this.zoneId = zoneId;
if (!(entry instanceof AttributeKvEntry attributeKvEntry)) {
throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName());
}
this.ts = attributeKvEntry.getLastUpdateTs();
this.version = attributeKvEntry.getVersion();
this.perimeterDefinition = JacksonUtil.fromString(entry.getValueAsString(), PerimeterDefinition.class);
}
public GeofencingZoneState(GeofencingZoneProto proto) {
this.zoneId = ProtoUtils.fromProto(proto.getZoneId());
this.ts = proto.getTs();
this.version = proto.getVersion();
this.perimeterDefinition = JacksonUtil.fromString(proto.getPerimeterDefinition(), PerimeterDefinition.class);
if (proto.hasInside()) {
this.lastPresence = proto.getInside() ? INSIDE : OUTSIDE;
}
}
public boolean update(GeofencingZoneState newZoneState) {
if (newZoneState.getTs() <= this.ts) {
return false;
}
Long newVersion = newZoneState.getVersion();
if (newVersion == null || this.version == null || newVersion > this.version) {
this.ts = newZoneState.getTs();
this.version = newVersion;
this.perimeterDefinition = newZoneState.getPerimeterDefinition();
this.lastPresence = null;
return true;
}
return false;
}
public GeofencingEvalResult evaluate(Coordinates entityCoordinates) {
boolean nowInside = perimeterDefinition.checkMatches(entityCoordinates);
GeofencingPresenceStatus status = nowInside ? INSIDE : OUTSIDE;
// first evaluation
if (this.lastPresence == null) {
this.lastPresence = status;
GeofencingTransitionEvent transition = null;
if (status == GeofencingPresenceStatus.INSIDE) {
transition = GeofencingTransitionEvent.ENTERED;
}
return new GeofencingEvalResult(transition, status);
}
// State changed
if (this.lastPresence != status) {
this.lastPresence = status;
GeofencingTransitionEvent transition = (status == GeofencingPresenceStatus.INSIDE) ?
GeofencingTransitionEvent.ENTERED : GeofencingTransitionEvent.LEFT;
return new GeofencingEvalResult(transition, status);
}
// State unchanged
return new GeofencingEvalResult(null, status);
}
}

13
application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java

@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ExportableEntity;
import org.thingsboard.server.common.data.HasVersion;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
@ -153,11 +154,13 @@ public class DefaultEntityExportService<I extends EntityId, E extends Exportable
List<CalculatedField> calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(ctx.getTenantId(), entityId);
calculatedFields.forEach(calculatedField -> {
calculatedField.setEntityId(getExternalIdOrElseInternal(ctx, entityId));
calculatedField.getConfiguration().getArguments().values().forEach(argument -> {
if (argument.getRefEntityId() != null) {
argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId()));
}
});
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) {
configuration.getArguments().values().forEach(argument -> {
if (argument.getRefEntityId() != null) {
argument.setRefEntityId(getExternalIdOrElseInternal(ctx, argument.getRefEntityId()));
}
});
}
});
return calculatedFields;
}

13
application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java

@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.HasVersion;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
@ -321,11 +322,13 @@ public abstract class BaseEntityImportService<I extends EntityId, E extends Expo
.peek(calculatedField -> {
calculatedField.setTenantId(ctx.getTenantId());
calculatedField.setEntityId(savedEntity.getId());
calculatedField.getConfiguration().getArguments().values().forEach(argument -> {
if (argument.getRefEntityId() != null) {
argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt()));
}
});
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration configuration) {
configuration.getArguments().values().forEach(argument -> {
if (argument.getRefEntityId() != null) {
argument.setRefEntityId(idProvider.getInternalId(argument.getRefEntityId(), ctx.isFinalImportAttempt()));
}
});
}
}).toList();
for (CalculatedField existingField : existing) {

75
application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java

@ -0,0 +1,75 @@
/**
* 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.utils;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import org.apache.commons.lang3.math.NumberUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.Optional;
public class CalculatedFieldArgumentUtils {
public static ListenableFuture<ArgumentEntry> transformSingleValueArgument(ListenableFuture<Optional<? extends KvEntry>> kvEntryFuture) {
return Futures.transform(kvEntryFuture, CalculatedFieldArgumentUtils::transformSingleValueArgument, MoreExecutors.directExecutor());
}
public static ArgumentEntry transformSingleValueArgument(Optional<? extends KvEntry> kvEntry) {
if (kvEntry.isPresent() && kvEntry.get().getValue() != null) {
return ArgumentEntry.createSingleValueArgument(kvEntry.get());
} else {
return new SingleValueArgumentEntry();
}
}
public static KvEntry createDefaultKvEntry(Argument argument) {
String key = argument.getRefEntityKey().getKey();
String defaultValue = argument.getDefaultValue();
if (StringUtils.isBlank(defaultValue)) {
return new StringDataEntry(key, null);
}
if (NumberUtils.isParsable(defaultValue)) {
return new DoubleDataEntry(key, Double.parseDouble(defaultValue));
}
if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) {
return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue));
}
return new StringDataEntry(key, defaultValue);
}
public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) {
return switch (ctx.getCfType()) {
case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames());
case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames());
case GEOFENCING -> new GeofencingCalculatedFieldState(ctx.getArgNames());
};
}
}

52
application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java

@ -15,31 +15,43 @@
*/
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.geofencing.GeofencingPresenceStatus;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BasicKvEntry;
import org.thingsboard.server.common.util.KvProtoUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto;
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.GeofencingZoneProto;
import org.thingsboard.server.gen.transport.TransportProtos.SingleValueArgumentProto;
import org.thingsboard.server.gen.transport.TransportProtos.TsDoubleValProto;
import org.thingsboard.server.gen.transport.TransportProtos.TsRollingArgumentProto;
import org.thingsboard.server.gen.transport.TransportProtos.TsValueProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
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.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
public class CalculatedFieldUtils {
@ -79,6 +91,8 @@ public class CalculatedFieldUtils {
builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry));
} else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) {
builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry));
} else if (argEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry) {
builder.addGeofencingArguments(toGeofencingArgumentProto(argName, geofencingArgumentEntry));
}
});
return builder.build();
@ -108,6 +122,27 @@ public class CalculatedFieldUtils {
return builder.build();
}
private static GeofencingArgumentProto toGeofencingArgumentProto(String argName, GeofencingArgumentEntry geofencingArgumentEntry) {
Map<EntityId, GeofencingZoneState> zoneStates = geofencingArgumentEntry.getZoneStates();
GeofencingArgumentProto.Builder builder = GeofencingArgumentProto.newBuilder()
.setArgName(argName);
zoneStates.forEach((entityId, zoneState) ->
builder.addZones(toGeofencingZoneProto(entityId, zoneState)));
return builder.build();
}
private static GeofencingZoneProto toGeofencingZoneProto(EntityId entityId, GeofencingZoneState zoneState) {
GeofencingZoneProto.Builder builder = GeofencingZoneProto.newBuilder()
.setZoneId(ProtoUtils.toProto(entityId))
.setTs(zoneState.getTs())
.setVersion(zoneState.getVersion())
.setPerimeterDefinition(JacksonUtil.toString(zoneState.getPerimeterDefinition()));
if (zoneState.getLastPresence() != null) {
builder.setInside(zoneState.getLastPresence().equals(GeofencingPresenceStatus.INSIDE));
}
return builder.build();
}
public static CalculatedFieldState fromProto(CalculatedFieldStateProto proto) {
if (StringUtils.isEmpty(proto.getType())) {
return null;
@ -118,6 +153,7 @@ public class CalculatedFieldUtils {
CalculatedFieldState state = switch (type) {
case SIMPLE -> new SimpleCalculatedFieldState();
case SCRIPT -> new ScriptCalculatedFieldState();
case GEOFENCING -> new GeofencingCalculatedFieldState();
};
proto.getSingleValueArgumentsList().forEach(argProto ->
@ -128,6 +164,11 @@ public class CalculatedFieldUtils {
state.getArguments().put(argProto.getKey(), fromRollingArgumentProto(argProto)));
}
if (CalculatedFieldType.GEOFENCING.equals(type)) {
proto.getGeofencingArgumentsList().forEach(argProto ->
state.getArguments().put(argProto.getArgName(), fromGeofencingArgumentProto(argProto)));
}
return state;
}
@ -149,4 +190,15 @@ public class CalculatedFieldUtils {
return new TsRollingArgumentEntry(tsRecords, proto.getLimit(), proto.getTimeWindow());
}
private static ArgumentEntry fromGeofencingArgumentProto(GeofencingArgumentProto proto) {
Map<EntityId, GeofencingZoneState> zoneStates = proto.getZonesList()
.stream()
.map(GeofencingZoneState::new)
.collect(Collectors.toMap(GeofencingZoneState::getZoneId, Function.identity()));
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
geofencingArgumentEntry.setZoneStates(zoneStates);
return geofencingArgumentEntry;
}
}

3
application/src/main/resources/logback.xml

@ -56,6 +56,9 @@
<!-- Device actor message processor debug -->
<!-- <logger name="org.thingsboard.server.actors.device.DeviceActorMessageProcessor" level="DEBUG" />-->
<!-- CF actors message processors trace -->
<!-- <logger name="org.thingsboard.server.actors.calculatedField" level="TRACE" />-->
<logger name="com.microsoft.azure.servicebus.primitives.CoreMessageReceiver" level="OFF" />
<root level="INFO">

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

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.cf;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
@ -23,28 +24,42 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
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.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.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.GeofencingCalculatedFieldConfiguration;
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;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.controller.CalculatedFieldControllerTest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
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.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.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest {
@ -121,7 +136,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);
@ -133,7 +148,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)
@ -606,6 +621,339 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
@Test
public void testGeofencingCalculatedField_withZonesCreatedOnDevice() throws Exception {
// --- Arrange entities ---
Device device = createDevice("GF Test Device", "sn-geo-2");
// Allowed zone polygon (square)
String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
// Restricted zone polygon (square)
String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"allowedZone\":" + allowedPolygon + "}")).andExpect(status().isOk());
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
// Initial device coordinates (inside Allowed, outside Restricted)
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
// --- Build CF: GEOFENCING ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getDeviceProfileId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("Geofencing CF");
cf.setDebugSettings(DebugSettings.off());
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST on the device
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone groups: ATTRIBUTE on the device
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("allowedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("restrictedZone", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
cfg.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
// Output to server attributes
Output out = new Output();
out.setType(OutputType.ATTRIBUTES);
out.setScope(AttributeScope.SERVER_SCOPE);
cfg.setOutput(out);
cf.setConfiguration(cfg);
doPost("/api/calculatedField", cf, CalculatedField.class);
// --- Assert initial evaluation (ENTERED / OUTSIDE) ---
await().alias("initial geofencing evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(),
"allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus", "restrictedZonesEvent");
// --- no restrictedZonesEvent as no transition happened yet
assertThat(attrs).isNotNull().isNotEmpty().hasSize(3);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
.containsEntry("allowedZonesStatus", "INSIDE")
.containsEntry("restrictedZonesStatus", "OUTSIDE");
});
// --- delete attributes reported in previous evaluation
doDelete("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/SERVER_SCOPE?keys=allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus", String.class);
// --- Update restrictedZone by 'restrictedZone' attribute update
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"restrictedZone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
// --- Assert no transition ---
// --- Assert attributes updated with the same values for restrictedZones ---
// --- Assert attributes updated with the new values for allowedZones ---
await().alias("evaluation after version bump of geo argument")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(),
"allowedZonesEvent", "allowedZonesStatus",
"restrictedZonesEvent", "restrictedZonesStatus");
assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesStatus", "INSIDE")
.containsEntry("restrictedZonesStatus", "OUTSIDE");
});
}
@Test
public void testGeofencingCalculatedField_withoutRelationsCreationAndDynamicRefresh() throws Exception {
// --- Arrange entities ---
Device device = createDevice("GF Device", "sn-geo-1");
// Allowed zone polygon (square)
String allowedPolygon = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
// Restricted zone polygon (square)
String restrictedPolygon = "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]";
Asset allowedZoneAsset = createAsset("Allowed Zone", null);
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk());
Asset restrictedZoneAsset = createAsset("Restricted Zone", null);
doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk());
// Relations from device to zones
EntityRelation deviceToAllowedZoneRelation = new EntityRelation();
deviceToAllowedZoneRelation.setFrom(device.getId());
deviceToAllowedZoneRelation.setTo(allowedZoneAsset.getId());
deviceToAllowedZoneRelation.setType("AllowedZone");
EntityRelation deviceToRestrictedZoneRelation = new EntityRelation();
deviceToRestrictedZoneRelation.setFrom(device.getId());
deviceToRestrictedZoneRelation.setTo(restrictedZoneAsset.getId());
deviceToRestrictedZoneRelation.setType("RestrictedZone");
doPost("/api/relation", deviceToAllowedZoneRelation).andExpect(status().isOk());
doPost("/api/relation", deviceToRestrictedZoneRelation).andExpect(status().isOk());
// Initial device coordinates (inside Allowed, outside Restricted)
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
// --- Build CF: GEOFENCING ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("Geofencing CF");
cf.setDebugSettings(DebugSettings.off());
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST on the device
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone groups: ATTRIBUTE on specific assets (one zone per group)
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("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("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(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
// Output to server attributes
Output out = new Output();
out.setType(OutputType.ATTRIBUTES);
out.setScope(AttributeScope.SERVER_SCOPE);
cfg.setOutput(out);
cf.setConfiguration(cfg);
doPost("/api/calculatedField", cf, CalculatedField.class);
// --- Assert initial evaluation (ENTERED / OUTSIDE) ---
await().alias("initial geofencing evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(),
"allowedZonesEvent", "allowedZonesStatus", "restrictedZonesStatus");
assertThat(attrs).isNotNull().isNotEmpty().hasSize(3);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
.containsEntry("allowedZonesStatus", "INSIDE")
.containsEntry("restrictedZonesStatus", "OUTSIDE");
});
// --- Move the device into Restricted zone (and outside Allowed) ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}"));
// --- Assert transition (LEFT / ENTERED) ---
await().alias("transition evaluation after movement")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(),
"allowedZonesEvent", "allowedZonesStatus",
"restrictedZonesEvent", "restrictedZonesStatus");
assertThat(attrs).isNotNull().isNotEmpty().hasSize(4);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
.containsEntry("restrictedZonesEvent", "ENTERED")
.containsEntry("allowedZonesStatus", "OUTSIDE")
.containsEntry("restrictedZonesStatus", "INSIDE");
});
}
@Test
public void testGeofencingCalculatedField_DynamicRefresh_RebindsZoneArguments() throws Exception {
// --- Update min allowed scheduled update intervals for CFs ---
loginSysAdmin();
EntityInfo tenantProfileEntityInfo = doGet("/api/tenantProfileInfo/default", EntityInfo.class);
assertThat(tenantProfileEntityInfo).isNotNull();
TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class);
assertThat(foundTenantProfile).isNotNull();
assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull();
foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10);
TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class);
assertThat(savedTenantProfile).isNotNull();
assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10);
loginTenantAdmin();
// --- Arrange entities ---
Device device = createDevice("GF Device dyn", "sn-geo-dyn-1");
// Allowed Zone A: covers initial point (ENTERED)
String allowedPolygonA = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
Asset allowedZoneA = createAsset("Allowed Zone A", null);
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneA.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonA + "}")).andExpect(status().isOk());
// Relation from device to Allowed Zone A
EntityRelation relAllowedA = new EntityRelation();
relAllowedA.setFrom(device.getId());
relAllowedA.setTo(allowedZoneA.getId());
relAllowedA.setType("AllowedZone");
doPost("/api/relation", relAllowedA).andExpect(status().isOk());
// Initial device coordinates: INSIDE Zone A
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")).andExpect(status().isOk());
// --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("Geofencing CF (dynamic refresh)");
cf.setDebugSettings(DebugSettings.off());
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
var allowedZonesGroup = new ZoneGroupConfiguration("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(Map.of("allowedZones", allowedZonesGroup));
// Server attributes output
Output out = new Output();
out.setType(OutputType.ATTRIBUTES);
out.setScope(AttributeScope.SERVER_SCOPE);
cfg.setOutput(out);
// Enable scheduled refresh with a 6-second interval
cfg.setScheduledUpdateInterval(6);
cf.setConfiguration(cfg);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class);
assertThat(savedCalculatedField).isNotNull();
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")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
.containsEntry("allowedZonesStatus", "INSIDE");
});
// --- Move device OUTSIDE Zone A (expect LEFT) ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
await().alias("outside zone A (LEFT)")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
.containsEntry("allowedZonesStatus", "OUTSIDE");
});
// --- Create Allowed Zone B covering the CURRENT location ---
String allowedPolygonB = "[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]";
Asset allowedZoneB = createAsset("Allowed Zone B", null);
doPost("/api/plugins/telemetry/ASSET/" + allowedZoneB.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonB + "}")).andExpect(status().isOk());
// Add a new relation
EntityRelation relAllowedB = new EntityRelation();
relAllowedB.setFrom(device.getId());
relAllowedB.setTo(allowedZoneB.getId());
relAllowedB.setType("AllowedZone");
doPost("/api/relation", relAllowedB).andExpect(status().isOk());
awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId());
// --- Same coordinates as before, but now we expect ENTERED since a new zone is registered ---
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk());
// --- Assert dynamic refresh picks up new relation and flips event back to ENTERED on the next telemetry update ---
await().alias("dynamic refresh rebinds allowedZones")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(1, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = getServerAttributes(device.getId(), "allowedZonesEvent", "allowedZonesStatus");
assertThat(attrs).isNotNull().isNotEmpty().hasSize(2);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
.containsEntry("allowedZonesStatus", "INSIDE");
});
}
private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception {
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class);
}
@ -621,4 +969,12 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
return doPost("/api/asset", asset, Asset.class);
}
private static Map<String, String> kv(ArrayNode attrs) {
Map<String, String> m = new HashMap<>();
for (JsonNode n : attrs) {
m.put(n.get("key").asText(), n.get("value").asText());
}
return m;
}
}

26
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -68,7 +68,10 @@ import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.actors.DefaultTbActorSystem;
import org.thingsboard.server.actors.TbActorId;
import org.thingsboard.server.actors.TbActorMailbox;
import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId;
import org.thingsboard.server.actors.TbEntityActorId;
import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActor;
import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityMessageProcessor;
import org.thingsboard.server.actors.device.DeviceActor;
import org.thingsboard.server.actors.device.DeviceActorMessageProcessor;
import org.thingsboard.server.actors.device.SessionInfo;
@ -99,6 +102,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo
import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
@ -150,6 +154,7 @@ import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.queue.memory.InMemoryStorage;
import org.thingsboard.server.service.cf.CfRocksDb;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
@ -1099,6 +1104,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
});
}
protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) {
CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId);
Map<CalculatedFieldId, CalculatedFieldState> statesMap = (Map<CalculatedFieldId, CalculatedFieldState>) ReflectionTestUtils.getField(processor, "states");
Awaitility.await("CF state for entity actor marked as dirty").atMost(5, TimeUnit.SECONDS).until(() -> {
CalculatedFieldState calculatedFieldState = statesMap.get(cfId);
boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty();
log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty);
return stateDirty;
});
}
protected static String getMapName(FeatureType featureType) {
switch (featureType) {
case ATTRIBUTES:
@ -1120,6 +1136,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
}
protected CalculatedFieldEntityMessageProcessor getCalculatedFieldEntityMessageProcessor(EntityId entityId) {
DefaultTbActorSystem actorSystem = (DefaultTbActorSystem) ReflectionTestUtils.getField(actorService, "system");
ConcurrentMap<TbActorId, TbActorMailbox> actors = (ConcurrentMap<TbActorId, TbActorMailbox>) ReflectionTestUtils.getField(actorSystem, "actors");
Awaitility.await("CF entity actor was created").atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> actors.containsKey(new TbCalculatedFieldEntityActorId(entityId)));
TbActorMailbox actorMailbox = actors.get(new TbCalculatedFieldEntityActorId(entityId));
CalculatedFieldEntityActor actor = (CalculatedFieldEntityActor) ReflectionTestUtils.getField(actorMailbox, "actor");
return (CalculatedFieldEntityMessageProcessor) ReflectionTestUtils.getField(actor, "processor");
}
protected void updateDefaultTenantProfileConfig(Consumer<DefaultTenantProfileConfiguration> updater) throws ThingsboardException {
updateDefaultTenantProfile(tenantProfile -> {
TenantProfileData profileData = tenantProfile.getProfileData();

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

@ -0,0 +1,483 @@
/**
* 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.service.cf.ctx.state;
import com.google.common.util.concurrent.Futures;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
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.CalculatedFieldConfiguration;
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.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy;
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;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
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.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.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@ExtendWith(MockitoExtension.class)
public class GeofencingCalculatedFieldStateTest {
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("8f83eeca-b5cd-4955-9241-09d1393768c6"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("688b529d-cfbe-4430-91c5-60b4f4e5d3cf"));
private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714"));
private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4"));
private final SingleValueArgumentEntry latitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("latitude", 50.4730), 145L);
private final SingleValueArgumentEntry longitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new DoubleDataEntry("longitude", 30.5050), 165L);
private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]");
private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, System.currentTimeMillis(), 0L);
private final GeofencingArgumentEntry geofencingAllowedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry));
private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]");
private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, System.currentTimeMillis(), 0L);
private final GeofencingArgumentEntry geofencingRestrictedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_2_ID, restrictedZoneAttributeKvEntry));
private GeofencingCalculatedFieldState state;
private CalculatedFieldCtx ctx;
@Mock
private ApiLimitService apiLimitService;
@Mock
private RelationService relationService;
@BeforeEach
void setUp() {
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, relationService);
ctx.init();
state = new GeofencingCalculatedFieldState(ctx.getArgNames());
}
@Test
void testType() {
assertThat(state.getType()).isEqualTo(CalculatedFieldType.GEOFENCING);
}
@Test
void testUpdateState() {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry
));
Map<String, ArgumentEntry> newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry);
boolean stateUpdated = state.updateState(ctx, newArgs);
assertThat(stateUpdated).isTrue();
assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf(
Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry
)
);
}
@Test
void testUpdateStateWithInvalidArgumentTypeForLatitudeArgument() {
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for latitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed.");
}
@Test
void testUpdateStateWithInvalidArgumentTypeForLongitudeArgument() {
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for longitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed.");
}
@Test
void testUpdateStateWithInvalidArgumentTypeForGeofencingArgument() {
assertThatThrownBy(() -> state.updateState(ctx, Map.of("someArgumentName", latitudeArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for someArgumentName argument: SINGLE_VALUE. Only GEOFENCING type is allowed.");
}
@Test
void testUpdateStateWhenUpdateExistingSingleValueArgumentEntry() {
state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry));
SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 190L);
Map<String, ArgumentEntry> newArgs = Map.of("latitude", newArgEntry);
boolean stateUpdated = state.updateState(ctx, newArgs);
assertThat(stateUpdated).isTrue();
assertThat(state.getArguments()).isEqualTo(newArgs);
}
@Test
void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithTheSameValue() {
state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry));
Map<String, ArgumentEntry> newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry);
boolean stateUpdated = state.updateState(ctx, newArgs);
assertThat(stateUpdated).isFalse();
assertThat(state.getArguments()).isEqualTo(newArgs);
}
@Test
void testUpdateStateWhenUpdateExistingSingleValueArgumentEntryWithValueOfAnotherType() {
state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry));
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for single value argument entry: GEOFENCING");
}
@Test
void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithValueOfAnotherType() {
state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry));
assertThatThrownBy(() -> state.updateState(ctx, Map.of("allowedZones", latitudeArgEntry)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE");
}
@Test
void testIsReadyWhenNotAllArgPresent() {
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenAllArgPresent() {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
assertThat(state.isReady()).isTrue();
}
@Test
void testIsReadyWhenEmptyEntryPresents() {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
state.getArguments().put("noParkingZones", new GeofencingArgumentEntry());
assertThat(state.isReady()).isFalse();
}
@Test
void testPerformCalculation() throws ExecutionException, InterruptedException {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
Output output = ctx.getOutput();
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesEvent", "ENTERED")
.put("allowedZonesStatus", "INSIDE")
.put("restrictedZonesStatus", "OUTSIDE")
);
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
// move the device to new coordinates → leaves allowed, enters restricted
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesEvent", "LEFT")
.put("allowedZonesStatus", "OUTSIDE")
.put("restrictedZonesEvent", "ENTERED")
.put("restrictedZonesStatus", "INSIDE")
);
// Check relations are created and deleted correctly for both iterations.
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
List<EntityRelation> saveValues = saveCaptor.getAllValues();
assertThat(saveValues).hasSize(2);
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
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("CurrentZone");
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
EntityRelation leftRelation = deleteCaptor.getValue();
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
}
@Test
void testPerformCalculationWithOnlyTransitionEventsReportingStrategy() throws ExecutionException, InterruptedException {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
Output output = ctx.getOutput();
var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_ONLY);
ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig));
ctx.init();
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(
JacksonUtil.newObjectNode().put("allowedZonesEvent", "ENTERED")
);
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
// move the device to new coordinates → leaves allowed, enters restricted
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesEvent", "LEFT")
.put("restrictedZonesEvent", "ENTERED")
);
// Check relations are created and deleted correctly for both iterations.
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
List<EntityRelation> saveValues = saveCaptor.getAllValues();
assertThat(saveValues).hasSize(2);
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
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("CurrentZone");
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
EntityRelation leftRelation = deleteCaptor.getValue();
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
}
@Test
void testPerformCalculationWithOnlyPresenceStatusReportingStrategy() throws ExecutionException, InterruptedException {
state.arguments = new HashMap<>(Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry,
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry,
"allowedZones", geofencingAllowedZoneArgEntry,
"restrictedZones", geofencingRestrictedZoneArgEntry
));
Output output = ctx.getOutput();
var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_PRESENCE_STATUS_ONLY);
ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig));
ctx.init();
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true));
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(output.getType());
assertThat(result.getScope()).isEqualTo(output.getScope());
assertThat(result.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesStatus", "INSIDE")
.put("restrictedZonesStatus", "OUTSIDE")
);
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L);
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L);
// move the device to new coordinates → leaves allowed, enters restricted
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude));
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result2).isNotNull();
assertThat(result2.getType()).isEqualTo(output.getType());
assertThat(result2.getScope()).isEqualTo(output.getScope());
assertThat(result2.getResult()).isEqualTo(
JacksonUtil.newObjectNode()
.put("allowedZonesStatus", "OUTSIDE")
.put("restrictedZonesStatus", "INSIDE")
);
// Check relations are created and deleted correctly for both iterations.
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture());
List<EntityRelation> saveValues = saveCaptor.getAllValues();
assertThat(saveValues).hasSize(2);
EntityRelation relationFromFirstIteration = saveValues.get(0);
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId());
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID);
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("CurrentZone");
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class);
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture());
EntityRelation leftRelation = deleteCaptor.getValue();
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID);
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId());
}
private CalculatedField getCalculatedField() {
return getCalculatedField(getCalculatedFieldConfig(REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS));
}
private CalculatedField getCalculatedField(CalculatedFieldConfiguration configuration) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setTenantId(TENANT_ID);
calculatedField.setEntityId(DEVICE_ID);
calculatedField.setType(CalculatedFieldType.GEOFENCING);
calculatedField.setName("Test Geofencing Calculated Field");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(configuration);
calculatedField.setVersion(1L);
return calculatedField;
}
private CalculatedFieldConfiguration getCalculatedFieldConfig(GeofencingReportStrategy reportStrategy) {
var config = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
config.setEntityCoordinates(entityCoordinates);
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("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("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(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
return config;
}
}

184
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingValueArgumentEntryTest.java

@ -0,0 +1,184 @@
/**
* 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.service.cf.ctx.state;
import io.hypersistence.utils.hibernate.type.json.internal.JacksonUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.geo.PerimeterDefinition;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class GeofencingValueArgumentEntryTest {
private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714"));
private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4"));
private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]");
private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, 363L, 155L);
private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]");
private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, 363L, 155L);
private GeofencingArgumentEntry entry;
@BeforeEach
void setUp() {
entry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry));
}
@Test
void testArgumentEntryType() {
assertThat(entry.getType()).isEqualTo(ArgumentEntryType.GEOFENCING);
}
@Test
void testUpdateEntryWhenSingleEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE");
}
@Test
void testUpdateEntryWhenRollingEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for geofencing argument entry: TS_ROLLING");
}
@Test
void testUpdateEntryWithTheSameTs() {
BaseAttributeKvEntry differentValueSameTs = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 363L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueSameTs, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isFalse();
}
@Test
@SuppressWarnings("unchecked")
void testUpdateEntryWhenNewVersionIsNull() {
BaseAttributeKvEntry differentValueNewVersionIsNull = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, null);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsNull, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isTrue();
assertThat(entry.getValue()).isInstanceOf(Map.class);
Map<EntityId, GeofencingZoneState> value = (Map<EntityId, GeofencingZoneState>) entry.getValue();
assertThat(value).hasSize(2);
assertThat(value.get(ZONE_1_ID).getVersion()).isNull();
assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L);
assertThat(value.get(ZONE_1_ID).getPerimeterDefinition())
.isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsNull.getJsonValue().get(), PerimeterDefinition.class));
assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L);
assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L);
assertThat(value.get(ZONE_2_ID).getPerimeterDefinition())
.isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class));
}
@Test
@SuppressWarnings("unchecked")
void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() {
BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isTrue();
assertThat(entry.getValue()).isInstanceOf(Map.class);
Map<EntityId, GeofencingZoneState> value = (Map<EntityId, GeofencingZoneState>) entry.getValue();
assertThat(value).hasSize(2);
assertThat(value.get(ZONE_1_ID).getVersion()).isEqualTo(156L);
assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L);
assertThat(value.get(ZONE_1_ID).getPerimeterDefinition())
.isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsSet.getJsonValue().get(), PerimeterDefinition.class));
assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L);
assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L);
assertThat(value.get(ZONE_2_ID).getPerimeterDefinition())
.isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class));
}
@Test
void testUpdateEntryWhenNewVersionIsLessThanCurrent() {
BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 154L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isFalse();
}
@Test
void testUpdateEntryWhenNewTsAndVersionIsGreaterThenCurrentAndValueWasNotChanged() {
BaseAttributeKvEntry newTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 364L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, newTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isTrue();
}
@Test
void testUpdateEntryWithOldTs() {
BaseAttributeKvEntry oldTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 362L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, oldTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry));
assertThat(entry.updateEntry(updated)).isFalse();
}
@Test
void testUpdateEntryWithNewZone() {
final AssetId NEW_ZONE_ID = new AssetId(UUID.fromString("a3eacf1a-6af3-4e9f-87c4-502bb25c7dc3"));
BaseAttributeKvEntry newZone = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L);
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry, NEW_ZONE_ID, newZone));
assertThat(entry.updateEntry(updated)).isTrue();
}
@Test
void testIsEmpty() {
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
assertThat(geofencingArgumentEntry.isEmpty()).isTrue();
}
@Test
void testIsEmptyWithEmptyMap() {
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(Map.of());
assertThat(geofencingArgumentEntry.isEmpty()).isTrue();
}
@Test
void testInvalidKvEntryDataTypeForZoneResultInEmptyArgument() {
BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new StringDataEntry("zone", "someString"), 363L, 155L);
assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)))
.isExactlyInstanceOf(IllegalArgumentException.class)
.hasMessage("The given string value cannot be transformed to Json object: someString");
}
@Test
void testNotParsableToPerimeterJsonKvEntryResultInExceptionTrowed() {
BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new JsonDataEntry("zone", "\"{}\""), 363L, 155L);
assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)))
.isExactlyInstanceOf(IllegalArgumentException.class)
.hasMessage("The given string value cannot be transformed to Json object: \"{}\"");
}
}

169
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingZoneStateTest.java

@ -0,0 +1,169 @@
/**
* 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.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.geo.Coordinates;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingEvalResult;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.ENTERED;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.LEFT;
public class GeofencingZoneStateTest {
private final AssetId ZONE_ID = new AssetId(UUID.fromString("628730fd-d625-417f-9c6d-ae9fe4addbdb"));
private GeofencingZoneState state;
@BeforeEach
void setUp() {
String POLYGON = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]";
state = new GeofencingZoneState(ZONE_ID, new BaseAttributeKvEntry(new JsonDataEntry("zone", POLYGON), 100L, 1L));
}
@Test
void evaluate_initialInside_thenInsideAgain() {
var inside = new Coordinates(50.4730, 30.5050);
// first evaluation: no prior state -> ENTERED
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE));
// same position again -> INSIDE (steady state)
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE));
}
@Test
void evaluate_initialOutside_thenOutsideAgain() {
var outside = new Coordinates(50.4760, 30.5110);
// first evaluation: no prior state -> OUTSIDE
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
// same position again -> OUTSIDE (steady state)
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
}
@Test
void evaluate_inside_thenLeave() {
var inside = new Coordinates(50.4730, 30.5050);
var outside = new Coordinates(50.4760, 30.5110);
// enter
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE));
// leave -> LEFT
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE));
// still outside -> OUTSIDE
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
}
@Test
void evaluate_outside_thenEnter() {
var outside = new Coordinates(50.4760, 30.5110);
var inside = new Coordinates(50.4730, 30.5050);
// start outside
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE));
// cross boundary -> ENTERED
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE));
// remain inside -> INSIDE
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE));
}
@Test
void update_withNewerVersion_updatesState_andResetsPresence() {
// arrange: establish a prior presence to ensure it’s reset on update
var inside = new Coordinates(50.4730, 30.5050);
assertThat(state.evaluate(inside)).isNotNull(); // sets lastPresence internally
String NEW_POLYGON = "[[50.470000, 30.502000], [50.470000, 30.503000], [50.471000, 30.503000], [50.471000, 30.502000]]";
GeofencingZoneState newer = new GeofencingZoneState(
ZONE_ID,
new BaseAttributeKvEntry(new JsonDataEntry("zone", NEW_POLYGON), 200L, 2L)
);
// act
boolean changed = state.update(newer);
// assert
assertThat(changed).isTrue();
assertThat(state.getTs()).isEqualTo(200L);
assertThat(state.getVersion()).isEqualTo(2L);
assertThat(state.getPerimeterDefinition()).isNotNull();
assertThat(state.getLastPresence()).isNull(); // must be reset on successful update
}
@Test
void update_withEqualVersion_doesNothing() {
// arrange: same version (1L) but different ts/polygon should still be ignored
String SOME_POLYGON = "[[50.472500, 30.504500], [50.472500, 30.505500], [50.473500, 30.505500], [50.473500, 30.504500]]";
GeofencingZoneState sameVersion = new GeofencingZoneState(
ZONE_ID,
new BaseAttributeKvEntry(new JsonDataEntry("zone", SOME_POLYGON), 300L, 1L)
);
// act
boolean changed = state.update(sameVersion);
// assert: nothing changes
assertThat(changed).isFalse();
assertThat(state.getTs()).isEqualTo(100L);
assertThat(state.getVersion()).isEqualTo(1L);
}
@Test
void update_withNullNewVersion_alwaysApplies_andCopiesNull() {
// arrange: the implementation updates if newVersion == null
String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]";
GeofencingZoneState nullVersion = new GeofencingZoneState(
ZONE_ID,
new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, null)
);
// act
boolean changed = state.update(nullVersion);
// assert: applied and version copied as null
assertThat(changed).isTrue();
assertThat(state.getTs()).isEqualTo(400L);
assertThat(state.getVersion()).isNull();
assertThat(state.getLastPresence()).isNull();
}
@Test
void update_withNewVersionWhenExistingIsNull_alwaysApplies_andCopiesNew() {
// arrange: the implementation updates if newVersion == null
String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]";
GeofencingZoneState newVersion = new GeofencingZoneState(
ZONE_ID,
new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, 2L)
);
state.setVersion(null);
// act
boolean changed = state.update(newVersion);
// assert: applied and version copied as null
assertThat(changed).isTrue();
assertThat(state.getTs()).isEqualTo(400L);
assertThat(state.getVersion()).isEqualTo(2);
assertThat(state.getLastPresence()).isNull();
}
}

7
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java

@ -44,6 +44,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
@ -77,7 +78,7 @@ public class ScriptCalculatedFieldStateTest {
@BeforeEach
void setUp() {
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService);
ctx = new CalculatedFieldCtx(getCalculatedField(), tbelInvokeService, apiLimitService, null);
ctx.init();
state = new ScriptCalculatedFieldState(ctx.getArgNames());
}
@ -124,7 +125,7 @@ public class ScriptCalculatedFieldStateTest {
void testPerformCalculation() throws ExecutionException, InterruptedException {
state.arguments = new HashMap<>(Map.of("deviceTemperature", deviceTemperatureArgEntry, "assetHumidity", assetHumidityArgEntry));
CalculatedFieldResult result = state.performCalculation(ctx).get();
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
@ -140,7 +141,7 @@ public class ScriptCalculatedFieldStateTest {
"assetHumidity", new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new LongDataEntry("a", 45L), 10L)
));
CalculatedFieldResult result = state.performCalculation(ctx).get();
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();

10
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java

@ -71,7 +71,7 @@ public class SimpleCalculatedFieldStateTest {
@BeforeEach
void setUp() {
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService);
ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, null);
ctx.init();
state = new SimpleCalculatedFieldState(ctx.getArgNames());
}
@ -134,7 +134,7 @@ public class SimpleCalculatedFieldStateTest {
"key3", key3ArgEntry
));
CalculatedFieldResult result = state.performCalculation(ctx).get();
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
@ -151,7 +151,7 @@ public class SimpleCalculatedFieldStateTest {
"key3", key3ArgEntry
));
assertThatThrownBy(() -> state.performCalculation(ctx))
assertThatThrownBy(() -> state.performCalculation(ctx.getEntityId(), ctx))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument 'key2' is not a number.");
}
@ -164,7 +164,7 @@ public class SimpleCalculatedFieldStateTest {
"key3", key3ArgEntry
));
CalculatedFieldResult result = state.performCalculation(ctx).get();
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
Output output = getCalculatedFieldConfig().getOutput();
@ -185,7 +185,7 @@ public class SimpleCalculatedFieldStateTest {
output.setDecimalsByDefault(3);
ctx.setOutput(output);
CalculatedFieldResult result = state.performCalculation(ctx).get();
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(output.getType());

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());
});
}

109
application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java

@ -0,0 +1,109 @@
/**
* 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.utils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@ExtendWith(MockitoExtension.class)
class CalculatedFieldUtilsTest {
private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("0a69e1e2-fcbc-4234-a4cd-3844bf54035c"));
private static final CalculatedFieldId CF_ID = CalculatedFieldId.fromString("ec0e91b9-6f27-4e93-946a-5fbc2707d8bc");
private static final DeviceId DEVICE_ID = DeviceId.fromString("1e03bd38-2010-4739-9362-160c288e36c4");
@Test
void toProtoAndFromProto_shouldMapGeofencingArgumentsAndZones() {
// given
CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class);
given(stateId.tenantId()).willReturn(TENANT_ID);
given(stateId.cfId()).willReturn(CF_ID);
given(stateId.entityId()).willReturn(DEVICE_ID);
// Build a geofencing argument with two zones (one with inside=true, one with inside=null)
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry();
Map<EntityId, GeofencingZoneState> zoneStates = new LinkedHashMap<>();
UUID zoneId1 = UUID.fromString("624a8fff-71a2-4847-a100-ff1cf52dbe71");
UUID zoneId2 = UUID.fromString("e2adf6ce-9478-40b1-b0e9-4a6860cc46bb");
AssetId z1 = new AssetId(zoneId1);
AssetId z2 = new AssetId(zoneId2);
JsonDataEntry zone1 = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]");
JsonDataEntry zone2 = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]");
BaseAttributeKvEntry zone1PerimeterAttribute = new BaseAttributeKvEntry(zone1, System.currentTimeMillis(), 0L);
BaseAttributeKvEntry zone2PerimeterAttribute = new BaseAttributeKvEntry(zone2, System.currentTimeMillis(), 0L);
GeofencingZoneState s1 = new GeofencingZoneState(z1, zone1PerimeterAttribute);
s1.setLastPresence(GeofencingPresenceStatus.INSIDE);
GeofencingZoneState s2 = new GeofencingZoneState(z2, zone2PerimeterAttribute);
zoneStates.put(z1, s1);
zoneStates.put(z2, s2);
geofencingArgumentEntry.setZoneStates(zoneStates);
// Create cf state with the geofencing argument and add it to the state map
CalculatedFieldState state = new GeofencingCalculatedFieldState(List.of("geofencingArgumentTest"));
state.updateState(mock(CalculatedFieldCtx.class), Map.of("geofencingArgumentTest", geofencingArgumentEntry));
// when
CalculatedFieldStateProto proto = toProto(stateId, state);
// then
CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(proto);
assertThat(fromProto)
.usingRecursiveComparison()
.ignoringFields("requiredArguments")
.isEqualTo(state);
ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest");
assertThat(fromProtoArgument).isInstanceOf(GeofencingArgumentEntry.class);
GeofencingArgumentEntry fromProtoGeoArgument = (GeofencingArgumentEntry) fromProtoArgument;
assertThat(fromProtoGeoArgument.getZoneStates()).hasSize(2);
assertThat(fromProtoGeoArgument.getZoneStates().get(z1).getLastPresence()).isEqualTo(GeofencingPresenceStatus.INSIDE);
assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull();
}
}

3
common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java

@ -38,7 +38,6 @@ import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.common.msg.rpc.FromDeviceRpcResponse;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.gen.transport.TransportProtos.RestApiCallResponseMsgProto;
import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToCalculatedFieldNotificationMsg;
@ -81,7 +80,7 @@ public interface TbClusterService extends TbQueueClusterService {
void pushNotificationToTransport(String targetServiceId, ToTransportMsg response, TbQueueCallback callback);
void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, TransportProtos.ToCalculatedFieldMsg msg, TbQueueCallback callback);
void pushMsgToCalculatedFields(TenantId tenantId, EntityId entityId, ToCalculatedFieldMsg msg, TbQueueCallback callback);
void pushMsgToCalculatedFields(TopicPartitionInfo tpi, UUID msgId, ToCalculatedFieldMsg msg, TbQueueCallback callback);

2
common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java

@ -38,5 +38,7 @@ public class SystemParams {
String calculatedFieldDebugPerTenantLimitsConfiguration;
long maxArgumentsPerCF;
long maxDataPointsPerRollingArg;
int minAllowedScheduledUpdateIntervalInSecForCF;
int maxRelationLevelPerCfArgument;
TrendzSettings trendzSettings;
}

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

@ -17,6 +17,6 @@ package org.thingsboard.server.common.data.cf;
public enum CalculatedFieldType {
SIMPLE, SCRIPT
SIMPLE, SCRIPT, GEOFENCING
}

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

@ -26,10 +26,15 @@ public class Argument {
@Nullable
private EntityId refEntityId;
private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
private ReferencedEntityKey refEntityKey;
private String defaultValue;
private Integer limit;
private Long timeWindow;
public boolean hasDynamicSource() {
return refDynamicSourceConfiguration != null;
}
}

24
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.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;
import java.util.Map;
public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
Map<String, Argument> getArguments();
}

23
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 CalculatedFieldConfiguration {
public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration {
protected Map<String, Argument> arguments;
protected String expression;
@ -42,20 +42,13 @@ public abstract class BaseCalculatedFieldConfiguration implements CalculatedFiel
}
@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;
public void validate() {
if (arguments.containsKey("ctx")) {
throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used.");
}
if (arguments.values().stream().anyMatch(Argument::hasDynamicSource)) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support dynamic source configuration!");
}
}
}

7
application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldInitService.java → common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java

@ -13,7 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf;
package org.thingsboard.server.common.data.cf.configuration;
public enum CFArgumentDynamicSourceType {
RELATION_QUERY
public interface CalculatedFieldInitService {
}

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

@ -21,12 +21,14 @@ import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@ -35,7 +37,8 @@ import java.util.Map;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SimpleCalculatedFieldConfiguration.class, name = "SIMPLE"),
@JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT")
@JsonSubTypes.Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"),
@JsonSubTypes.Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CalculatedFieldConfiguration {
@ -43,19 +46,28 @@ public interface CalculatedFieldConfiguration {
@JsonIgnore
CalculatedFieldType getType();
Map<String, Argument> getArguments();
String getExpression();
void setExpression(String expression);
Output getOutput();
void validate();
@JsonIgnore
List<EntityId> getReferencedEntities();
default List<EntityId> getReferencedEntities() {
return Collections.emptyList();
}
List<CalculatedFieldLink> buildCalculatedFieldLinks(TenantId tenantId, EntityId cfEntityId, 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;
}
CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, 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());
}
}

39
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java

@ -0,0 +1,39 @@
/**
* 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 com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CfArgumentDynamicSourceConfiguration {
@JsonIgnore
CFArgumentDynamicSourceType getType();
default void validate() {}
}

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);
}

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

@ -0,0 +1,86 @@
/**
* 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 lombok.Data;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationsQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import java.util.Collections;
import java.util.List;
@Data
public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration {
private int maxLevel;
private boolean fetchLastLevelOnly;
private EntitySearchDirection direction;
private String relationType;
@Override
public CFArgumentDynamicSourceType getType() {
return CFArgumentDynamicSourceType.RELATION_QUERY;
}
@Override
public void validate() {
if (maxLevel < 1) {
throw new IllegalArgumentException("Relation query dynamic source configuration max relation level can't be less than 1!");
}
if (direction == null) {
throw new IllegalArgumentException("Relation query dynamic source configuration direction must be specified!");
}
if (StringUtils.isBlank(relationType)) {
throw new IllegalArgumentException("Relation query dynamic source configuration relation type must be specified!");
}
}
@JsonIgnore
public boolean isSimpleRelation() {
return maxLevel == 1;
}
public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) {
if (maxLevel > maxAllowedRelationLevel) {
throw new IllegalArgumentException("Max relation level is greater than configured " +
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName);
}
}
public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) {
if (isSimpleRelation()) {
throw new IllegalArgumentException("Entity relations query can't be created for a simple relation!");
}
var entityRelationsQuery = new EntityRelationsQuery();
entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly));
entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, Collections.emptyList())));
return entityRelationsQuery;
}
public List<EntityId> resolveEntityIds(List<EntityRelation> relations) {
return switch (direction) {
case FROM -> relations.stream().map(EntityRelation::getTo).toList();
case TO -> relations.stream().map(EntityRelation::getFrom).toList();
};
}
}

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

@ -0,0 +1,35 @@
/**
* 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;
public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
@JsonIgnore
boolean isScheduledUpdateEnabled();
int getScheduledUpdateInterval();
void setScheduledUpdateInterval(int interval);
default void validate(long minAllowedScheduledUpdateInterval) {
if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) {
throw new IllegalArgumentException("Scheduled update interval is less than configured " +
"minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval);
}
}
}

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

@ -21,7 +21,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@Data
@EqualsAndHashCode(callSuper = true)
public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements CalculatedFieldConfiguration {
public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
@Override
public CalculatedFieldType getType() {

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 CalculatedFieldConfiguration {
public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration {
private boolean useLatestTs;

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

@ -0,0 +1,57 @@
/**
* 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.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 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;
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.setRefEntityKey(new ReferencedEntityKey(keyName, ArgumentType.TS_LATEST, null));
return argument;
}
}

81
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.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 com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
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.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Data
public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration {
private EntityCoordinates entityCoordinates;
private Map<String, ZoneGroupConfiguration> zoneGroups;
private int scheduledUpdateInterval;
private Output output;
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.GEOFENCING;
}
@Override
@JsonIgnore
public Map<String, Argument> getArguments() {
Map<String, Argument> args = new HashMap<>(entityCoordinates.toArguments());
zoneGroups.forEach((zgName, zgConfig) -> args.put(zgName, zgConfig.toArgument()));
return args;
}
@Override
public List<EntityId> getReferencedEntities() {
return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList();
}
@Override
public Output getOutput() {
return output;
}
@Override
public boolean isScheduledUpdateEnabled() {
return scheduledUpdateInterval > 0 && zoneGroups.values().stream().anyMatch(ZoneGroupConfiguration::hasDynamicSource);
}
@Override
public void validate() {
if (entityCoordinates == null) {
throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!");
}
entityCoordinates.validate();
if (zoneGroups == null || zoneGroups.isEmpty()) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
zoneGroups.forEach((key, value) -> value.validate(key));
}
}

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

@ -0,0 +1,19 @@
/**
* 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;
public sealed interface GeofencingEvent
permits GeofencingTransitionEvent, GeofencingPresenceStatus { }

25
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingPresenceStatus.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.geofencing;
import lombok.Getter;
@Getter
public enum GeofencingPresenceStatus implements GeofencingEvent {
INSIDE, OUTSIDE;
}

24
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingReportStrategy.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.geofencing;
public enum GeofencingReportStrategy {
REPORT_TRANSITION_EVENTS_ONLY,
REPORT_PRESENCE_STATUS_ONLY,
REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS
}

20
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingTransitionEvent.java

@ -0,0 +1,20 @@
/**
* 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;
public enum GeofencingTransitionEvent implements GeofencingEvent {
ENTERED, LEFT
}

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

@ -0,0 +1,95 @@
/**
* 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 com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
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.ReferencedEntityKey;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ZoneGroupConfiguration {
@Nullable
private EntityId refEntityId;
private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
private final String perimeterKeyName;
private final GeofencingReportStrategy reportStrategy;
private final boolean createRelationsWithMatchedZones;
private String relationType;
private EntitySearchDirection direction;
public void validate(String name) {
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 (hasDynamicSource()) {
refDynamicSourceConfiguration.validate();
}
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;
}
@JsonIgnore
public boolean isCfEntitySource(EntityId cfEntityId) {
if (refEntityId == null && refDynamicSourceConfiguration == null) {
return true;
}
return refEntityId != null && refEntityId.equals(cfEntityId);
}
@JsonIgnore
public boolean isLinkedCfEntitySource(EntityId cfEntityId) {
return refEntityId != null && !refEntityId.equals(cfEntityId);
}
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;
}
}

4
common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java

@ -172,6 +172,10 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxCalculatedFieldsPerEntity = 5;
@Schema(example = "10")
private long maxArgumentsPerCF = 10;
@Schema(example = "3600")
private int minAllowedScheduledUpdateIntervalInSecForCF = 3600;
@Schema(example = "10")
private int maxRelationLevelPerCfArgument = 10;
@Builder.Default
@Min(value = 1, message = "must be at least 1")
@Schema(example = "1000")

38
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ArgumentTest.java

@ -0,0 +1,38 @@
/**
* 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 org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class ArgumentTest {
@Test
void validateShouldReturnFalseIfDynamicSourceConfigurationIsNull() {
var argument = new Argument();
assertThat(argument.hasDynamicSource()).isFalse();
}
@Test
void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
var argument = new Argument();
argument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
assertThat(argument.hasDynamicSource()).isTrue();
}
}

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

@ -0,0 +1,216 @@
/**
* 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 org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import java.util.List;
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.when;
@ExtendWith(MockitoExtension.class)
public class RelationQueryDynamicSourceConfigurationTest {
@Mock
EntityId rootEntityId;
@Mock
EntityRelation rel1;
@Mock
EntityRelation rel2;
@Test
void typeShouldBeRelationQuery() {
var cfg = new RelationQueryDynamicSourceConfiguration();
assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_QUERY);
}
@Test
void validateShouldThrowWhenMaxLevelLessThanOne() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(0);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation query dynamic source configuration max relation level can't be less than 1!");
}
@Test
void validateShouldThrowWhenMaxLevelGreaterThanMaxAllowedLevelFromTenantProfile() {
int maxAllowedRelationLevel = 2;
int argumentMaxRelationLevel = 3;
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(argumentMaxRelationLevel);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
String testRelationArgument = "testRelationArgument";
assertThatThrownBy(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Max relation level is greater than configured " +
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + testRelationArgument);
}
@Test
void validateShouldPassValidationWhenMaxLevelLessThanMaxAllowedLevelFromTenantProfile() {
int maxAllowedRelationLevel = 5;
int argumentMaxRelationLevel = 2;
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(argumentMaxRelationLevel);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
String testRelationArgument = "testRelationArgument";
assertThatCode(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)).doesNotThrowAnyException();
}
@Test
void validateShouldThrowWhenDirectionIsNull() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setDirection(null);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation query dynamic source configuration direction must be specified!");
}
@ParameterizedTest
@ValueSource(strings = {" "})
@NullAndEmptySource
void validateShouldThrowWhenRelationTypeIsNull(String relationType) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(relationType);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation query dynamic source configuration relation type must be specified!");
}
@ParameterizedTest
@NullAndEmptySource
void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull(List<EntityType> entityTypes) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
assertThat(cfg.isSimpleRelation()).isTrue();
}
@Test
void isSimpleRelationFalseWhenMaxLevelNotOne() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
assertThat(cfg.isSimpleRelation()).isFalse();
}
@ParameterizedTest
@NullAndEmptySource
void toEntityRelationsQueryShouldThrowForSimpleRelation(List<EntityType> entityTypes) {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(1);
cfg.setFetchLastLevelOnly(false);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatThrownBy(() -> cfg.toEntityRelationsQuery(rootEntityId))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity relations query can't be created for a simple relation!");
}
@Test
void toEntityRelationsQueryShouldBuildQueryForNonSimpleRelation() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
cfg.setFetchLastLevelOnly(true);
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.MANAGES_TYPE);
var query = cfg.toEntityRelationsQuery(rootEntityId);
assertThat(query).isNotNull();
RelationsSearchParameters params = query.getParameters();
assertThat(params).isNotNull();
assertThat(params.getRootId()).isEqualTo(rootEntityId.getId());
assertThat(params.getDirection()).isEqualTo(EntitySearchDirection.TO);
assertThat(params.getMaxLevel()).isEqualTo(2);
assertThat(params.isFetchLastLevelOnly()).isTrue();
assertThat(query.getFilters()).hasSize(1);
assertThat(query.getFilters().get(0)).isInstanceOf(RelationEntityTypeFilter.class);
RelationEntityTypeFilter filter = query.getFilters().get(0);
assertThat(filter.getRelationType()).isEqualTo(EntityRelation.MANAGES_TYPE);
}
@Test
void resolveEntityIdsFromDirectionFROMReturnsToIds() {
when(rel1.getTo()).thenReturn(mock(EntityId.class));
when(rel2.getTo()).thenReturn(mock(EntityId.class));
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setDirection(EntitySearchDirection.FROM);
var out = cfg.resolveEntityIds(List.of(rel1, rel2));
assertThat(out).containsExactly(rel1.getTo(), rel2.getTo());
}
@Test
void resolveEntityIdsFromDirectionTOReturnsFromIds() {
when(rel1.getFrom()).thenReturn(mock(EntityId.class));
when(rel2.getFrom()).thenReturn(mock(EntityId.class));
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
var out = cfg.resolveEntityIds(List.of(rel1, rel2));
assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom());
}
@Test
void validateShouldPassForValidConfig() {
var cfg = new RelationQueryDynamicSourceConfiguration();
cfg.setMaxLevel(2);
cfg.setFetchLastLevelOnly(false);
cfg.setDirection(EntitySearchDirection.FROM);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
assertThatCode(cfg::validate).doesNotThrowAnyException();
}
}

54
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java

@ -0,0 +1,54 @@
/**
* 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 org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ExtendWith(MockitoExtension.class)
public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest {
@Test
void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() {
int scheduledUpdateInterval = 60;
int minAllowedInterval = scheduledUpdateInterval - 1;
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setScheduledUpdateInterval(scheduledUpdateInterval);
assertThatCode(() -> cfg.validate(minAllowedInterval)).doesNotThrowAnyException();
}
@Test
void validateShouldThrowWhenScheduledUpdateIntervalIsLessThanMinAllowedIntervalInTenantProfile() {
int minAllowedInterval = (int) TimeUnit.HOURS.toSeconds(2);
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setScheduledUpdateInterval(1);
assertThatThrownBy(() -> cfg.validate(minAllowedInterval))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Scheduled update interval is less than configured " +
"minimum allowed interval in tenant profile: " + minAllowedInterval);
}
}

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();
}
}

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

@ -0,0 +1,161 @@
/**
* 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.api.extension.ExtendWith;
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.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import java.util.List;
import java.util.Map;
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.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 {
@Test
void typeShouldBeGeofencing() {
var cfg = new GeofencingCalculatedFieldConfiguration();
assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.GEOFENCING);
}
@Test
void validateShouldThrowWhenEntityCoordinatesNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field entity coordinates must be specified!");
}
@Test
void validateShouldThrowWhenZoneGroupsNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
cfg.setZoneGroups(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
@Test
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() {
var cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration));
cfg.validate();
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfiguration).validate("someGroupName");
}
@Test
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() {
var cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
var zoneGroupConfigurationA = mock(ZoneGroupConfiguration.class);
var zoneGroupConfigurationB = mock(ZoneGroupConfiguration.class);
String zoneGroupAName = "zoneGroupA";
String zoneGroupBName = "zoneGroupB";
cfg.setZoneGroups(Map.of("zoneGroupA", zoneGroupConfigurationA, "zoneGroupB", zoneGroupConfigurationB));
assertThatCode(cfg::validate).doesNotThrowAnyException();
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfigurationA).validate(zoneGroupAName);
verify(zoneGroupConfigurationB).validate(zoneGroupBName);
}
@Test
void scheduledUpdateDisabledWhenIntervalIsZero() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setScheduledUpdateInterval(0);
assertThat(cfg.isScheduledUpdateEnabled()).isFalse();
}
@Test
void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButNoZonesWithDynamicArguments() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class);
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(false);
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock));
cfg.setScheduledUpdateInterval(60);
assertThat(cfg.isScheduledUpdateEnabled()).isFalse();
}
@Test
void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() {
var cfg = new GeofencingCalculatedFieldConfiguration();
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class);
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(true);
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock));
cfg.setScheduledUpdateInterval(60);
assertThat(cfg.isScheduledUpdateEnabled()).isTrue();
}
@Test
void testGetArgumentsOverride() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
cfg.setZoneGroups(Map.of("allowedZones", new ZoneGroupConfiguration("perimeter", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false)));
Map<String, Argument> arguments = cfg.getArguments();
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));
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));
}
}

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

@ -0,0 +1,123 @@
/**
* 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.AttributeScope;
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.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
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.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
public class ZoneGroupConfigurationTest {
@ParameterizedTest
@ValueSource(strings = {EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY, EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY})
void validateShouldThrowWhenUsedReservedEntityCoordinateNames(String name) {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate(name))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) {
var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldThrowWhenReportStrategyIsNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenRelationCreationEnabledAndRelationTypeIsNullEmptyOrBlank(String relationType) {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
zoneGroupConfiguration.setRelationType(relationType);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation type must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldThrowWhenRelationCreationEnabledAndDirectionIsNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setDirection(null);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Relation direction must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationDisabledAndConfigValid() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException();
}
@Test
void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationEnabledAndConfigValid() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true);
zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setDirection(EntitySearchDirection.TO);
assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException();
}
@Test
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration());
assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue();
}
@Test
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() {
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class);
assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull();
assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse();
}
@Test
void validateToArgumentsMethodCallWithoutRefEntityId() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
Argument zoneGroupArgument = zoneGroupConfiguration.toArgument();
assertThat(zoneGroupArgument).isNotNull();
assertThat(zoneGroupArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
}
}

5
common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java

@ -147,7 +147,10 @@ public enum MsgType {
/* CF Manager Actor -> CF Entity actor */
CF_ENTITY_TELEMETRY_MSG,
CF_ENTITY_INIT_CF_MSG,
CF_ENTITY_DELETE_MSG;
CF_ENTITY_DELETE_MSG,
CF_DYNAMIC_ARGUMENTS_REFRESH_MSG,
CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG;
@Getter
private final boolean ignoreOnStart;

12
common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java

@ -1377,6 +1377,18 @@ public class ProtoUtils {
return TbMsg.fromProto(queueName, getTbMsgProto(ruleEngineMsg), callback);
}
public static TransportProtos.EntityIdProto toProto(EntityId entityId) {
return TransportProtos.EntityIdProto.newBuilder()
.setEntityIdMSB(getMsb(entityId))
.setEntityIdLSB(getLsb(entityId))
.setType(toProto(entityId.getEntityType()))
.build();
}
public static EntityId fromProto(TransportProtos.EntityIdProto entityIdProto) {
return EntityIdFactory.getByTypeAndUuid(fromProto(entityIdProto.getType()), new UUID(entityIdProto.getEntityIdMSB(), entityIdProto.getEntityIdLSB()));
}
private static boolean isNotNull(Object obj) {
return obj != null;
}

20
common/proto/src/main/proto/queue.proto

@ -82,6 +82,12 @@ enum ApiUsageRecordKeyProto {
INACTIVE_DEVICES = 10;
}
message EntityIdProto {
int64 entityIdMSB = 1;
int64 entityIdLSB = 2;
EntityTypeProto type = 4;
}
/**
* Service Discovery Data Structures;
*/
@ -896,11 +902,25 @@ message TsRollingArgumentProto {
repeated TsDoubleValProto tsValue = 4;
}
message GeofencingZoneProto {
EntityIdProto zoneId = 1;
int64 ts = 2;
string perimeterDefinition = 3;
int64 version = 4;
optional bool inside = 5;
}
message GeofencingArgumentProto {
string argName = 1;
repeated GeofencingZoneProto zones = 2;
}
message CalculatedFieldStateProto {
CalculatedFieldEntityCtxIdProto id = 1;
string type = 2;
repeated SingleValueArgumentProto singleValueArguments = 3;
repeated TsRollingArgumentProto rollingValueArguments = 4;
repeated GeofencingArgumentProto geofencingArguments = 5;
}
//Used to report session state to tb-Service and persist this state in the cache on the tb-Service level.

22
common/proto/src/test/java/org/thingsboard/server/common/util/ProtoUtilsTest.java

@ -22,6 +22,7 @@ import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.thingsboard.common.util.JacksonUtil;
@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKey;
@ -344,4 +346,24 @@ class ProtoUtilsTest {
assertThat(toDeviceRpcRequestActorMsg.getMsg()).isEqualTo(request);
}
@ParameterizedTest
@EnumSource(EntityType.class)
void testEntityIdProto_toProto_fromProto(EntityType entityType) {
UUID uuid = UUID.fromString("51a514d7-ea8f-496d-b567-f6e76f0f9b83");
EntityId original = EntityIdFactory.getByTypeAndUuid(entityType, uuid);
assertThat(original).isNotNull();
// toProto
TransportProtos.EntityIdProto proto = ProtoUtils.toProto(original);
assertThat(proto).isNotNull();
assertThat(proto.getType().getNumber()).isEqualTo(entityType.getProtoNumber());
assertThat(proto.getEntityIdMSB()).isEqualTo(uuid.getMostSignificantBits());
assertThat(proto.getEntityIdLSB()).isEqualTo(uuid.getLeastSignificantBits());
// fromProto
EntityId restored = ProtoUtils.fromProto(proto);
assertThat(restored).isNotNull().isEqualTo(original);
}
}

3
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java

@ -26,7 +26,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = TbelCfSingleValueArg.class, name = "SINGLE_VALUE"),
@JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING")
@JsonSubTypes.Type(value = TbelCfTsRollingArg.class, name = "TS_ROLLING"),
@JsonSubTypes.Type(value = TbelCfTsGeofencingArg.class, name = "GEOFENCING_CF_ARGUMENT_VALUE"),
})
public interface TbelCfArg extends TbelCfObject {

43
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java

@ -0,0 +1,43 @@
/**
* 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.script.api.tbel;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class TbelCfTsGeofencingArg implements TbelCfArg {
private final Object value;
@JsonCreator
public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) {
this.value = value;
}
@Override
public String getType() {
return "GEOFENCING_CF_ARGUMENT_VALUE";
}
@Override
public long memorySize() {
return OBJ_SIZE;
}
}

38
common/util/src/main/java/org/thingsboard/common/util/geo/CirclePerimeterDefinition.java

@ -0,0 +1,38 @@
/**
* 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.common.util.geo;
import lombok.Data;
@Data
public class CirclePerimeterDefinition implements PerimeterDefinition {
private final Double latitude;
private final Double longitude;
private final Double radius;
@Override
public PerimeterType getType() {
return PerimeterType.CIRCLE;
}
@Override
public boolean checkMatches(Coordinates entityCoordinates) {
Coordinates perimeterCoordinates = new Coordinates(latitude, longitude);
return radius > GeoUtil.distance(entityCoordinates, perimeterCoordinates, RangeUnit.METER);
}
}

35
common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinition.java

@ -0,0 +1,35 @@
/**
* 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.common.util.geo;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import java.io.Serializable;
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonDeserialize(using = PerimeterDefinitionDeserializer.class)
@JsonSerialize(using = PerimeterDefinitionSerializer.class)
public interface PerimeterDefinition extends Serializable {
@JsonIgnore
PerimeterType getType();
@JsonIgnore
boolean checkMatches(Coordinates entityCoordinates);
}

48
common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializer.java

@ -0,0 +1,48 @@
/**
* 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.common.util.geo;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.ObjectCodec;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
public class PerimeterDefinitionDeserializer extends JsonDeserializer<PerimeterDefinition> {
@Override
public PerimeterDefinition deserialize(JsonParser p, DeserializationContext ctx) throws IOException {
ObjectCodec codec = p.getCodec();
JsonNode node = codec.readTree(p);
if (node.isObject()) {
double latitude = node.get("latitude").asDouble();
double longitude = node.get("longitude").asDouble();
double radius = node.get("radius").asDouble();
return new CirclePerimeterDefinition(latitude, longitude, radius);
}
if (node.isArray()) {
ObjectMapper mapper = (ObjectMapper) p.getCodec();
String polygonStrDefinition = mapper.writeValueAsString(node);
return new PolygonPerimeterDefinition(polygonStrDefinition);
}
throw new IOException("Failed to deserialize PerimeterDefinition from node: " + node);
}
}

49
common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializer.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.common.util.geo;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.thingsboard.server.common.data.StringUtils;
import java.io.IOException;
public class PerimeterDefinitionSerializer extends JsonSerializer<PerimeterDefinition> {
@Override
public void serialize(PerimeterDefinition value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
if (value instanceof CirclePerimeterDefinition c) {
gen.writeStartObject();
gen.writeNumberField("latitude", c.getLatitude());
gen.writeNumberField("longitude", c.getLongitude());
gen.writeNumberField("radius", c.getRadius());
gen.writeEndObject();
return;
}
if (value instanceof PolygonPerimeterDefinition p) {
String raw = p.getPolygonDefinition();
if (StringUtils.isBlank(raw)) {
throw new IOException("Failed to serialize PolygonPerimeterDefinition with blank: " + value);
}
ObjectMapper mapper = (ObjectMapper) gen.getCodec();
gen.writeTree(mapper.readTree(raw));
return;
}
throw new IOException("Failed to serialize PerimeterDefinition from value: " + value);
}
}

35
common/util/src/main/java/org/thingsboard/common/util/geo/PolygonPerimeterDefinition.java

@ -0,0 +1,35 @@
/**
* 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.common.util.geo;
import lombok.Data;
@Data
public class PolygonPerimeterDefinition implements PerimeterDefinition {
private final String polygonDefinition;
@Override
public PerimeterType getType() {
return PerimeterType.POLYGON;
}
@Override
public boolean checkMatches(Coordinates entityCoordinates) {
return GeoUtil.contains(polygonDefinition, entityCoordinates);
}
}

50
common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionDeserializerTest.java

@ -0,0 +1,50 @@
/**
* 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.common.util.geo;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.JacksonUtil;
import static org.assertj.core.api.Assertions.assertThat;
public class PerimeterDefinitionDeserializerTest {
@Test
void shouldDeserializeCircle() {
String json = """
{"latitude":50.45,"longitude":30.52,"radius":100.0}""";
PerimeterDefinition def = JacksonUtil.fromString(json, PerimeterDefinition.class);
assertThat(def).isNotNull().isInstanceOf(CirclePerimeterDefinition.class);
CirclePerimeterDefinition circle = (CirclePerimeterDefinition) def;
assertThat(circle.getLatitude()).isEqualTo(50.45);
assertThat(circle.getLongitude()).isEqualTo(30.52);
assertThat(circle.getRadius()).isEqualTo(100.0);
}
@Test
void shouldDeserializePolygon() {
String json = "[[50.45,30.52],[50.46,30.53],[50.44,30.54]]";
PerimeterDefinition def = JacksonUtil.fromString(json, PerimeterDefinition.class);
assertThat(def).isInstanceOf(PolygonPerimeterDefinition.class);
PolygonPerimeterDefinition poly = (PolygonPerimeterDefinition) def;
assertThat(poly.getPolygonDefinition()).isEqualTo(json);
}
}

52
common/util/src/test/java/org/thingsboard/common/util/geo/PerimeterDefinitionSerializerTest.java

@ -0,0 +1,52 @@
/**
* 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.common.util.geo;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.JacksonUtil;
import static org.assertj.core.api.Assertions.assertThat;
public class PerimeterDefinitionSerializerTest {
@Test
void shouldSerializeCircle() {
PerimeterDefinition circle = new CirclePerimeterDefinition(50.45, 30.52, 120.0);
String json = JacksonUtil.writeValueAsString(circle);
JsonNode actual = JacksonUtil.toJsonNode(json);
assertThat(actual.get("latitude").asDouble()).isEqualTo(50.45);
assertThat(actual.get("longitude").asDouble()).isEqualTo(30.52);
assertThat(actual.get("radius").asDouble()).isEqualTo(120.0);
}
@Test
void shouldSerializePolygon() throws Exception {
String rawArray = "[[50.45,30.52],[50.46,30.53],[50.44,30.54]]";
PerimeterDefinition polygon = new PolygonPerimeterDefinition(rawArray);
String json = JacksonUtil.writeValueAsString(polygon);
JsonNode actual = JacksonUtil.toJsonNode(json);
JsonNode expected = JacksonUtil.toJsonNode(rawArray);
assertThat(actual).isEqualTo(expected);
assertThat(actual.isArray()).isTrue();
assertThat(actual.size()).isEqualTo(3);
}
}

2
dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java

@ -81,7 +81,7 @@ public abstract class AbstractEntityService {
@Autowired
@Lazy
private TbTenantProfileCache tbTenantProfileCache;
protected TbTenantProfileCache tbTenantProfileCache;
@Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes;

77
dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java

@ -18,7 +18,9 @@ package org.thingsboard.server.dao.service.validator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.cf.CalculatedFieldDao;
@ -26,6 +28,9 @@ import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import java.util.Map;
import java.util.stream.Collectors;
@Component
public class CalculatedFieldDataValidator extends DataValidator<CalculatedField> {
@ -36,10 +41,22 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
private ApiLimitService apiLimitService;
@Override
protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) {
validateNumberOfCFsPerEntity(tenantId, calculatedField.getEntityId());
protected void validateDataImpl(TenantId tenantId, CalculatedField calculatedField) {
validateNumberOfArgumentsPerCF(tenantId, calculatedField);
validateArgumentNames(calculatedField);
validateCalculatedFieldConfiguration(calculatedField);
validateSchedulingConfiguration(tenantId, calculatedField);
validateRelationQuerySourceArguments(tenantId, calculatedField);
}
@Override
protected void validateCreate(TenantId tenantId, CalculatedField calculatedField) {
long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity);
if (maxCFsPerEntity <= 0) {
return;
}
if (calculatedFieldDao.countCFByEntityId(tenantId, calculatedField.getEntityId()) >= maxCFsPerEntity) {
throw new DataValidationException("Calculated fields per entity limit reached!");
}
}
@Override
@ -48,34 +65,56 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
if (old == null) {
throw new DataValidationException("Can't update non existing calculated field!");
}
validateNumberOfArgumentsPerCF(tenantId, calculatedField);
validateArgumentNames(calculatedField);
return old;
}
private void validateNumberOfCFsPerEntity(TenantId tenantId, EntityId entityId) {
long maxCFsPerEntity = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxCalculatedFieldsPerEntity);
if (maxCFsPerEntity <= 0) {
private void validateNumberOfArgumentsPerCF(TenantId tenantId, CalculatedField calculatedField) {
if (!(calculatedField instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) {
return;
}
if (calculatedFieldDao.countCFByEntityId(tenantId, entityId) >= maxCFsPerEntity) {
throw new DataValidationException("Calculated fields per entity limit reached!");
}
}
private void validateNumberOfArgumentsPerCF(TenantId tenantId, CalculatedField calculatedField) {
long maxArgumentsPerCF = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxArgumentsPerCF);
if (maxArgumentsPerCF <= 0) {
return;
}
if (calculatedField.getConfiguration().getArguments().size() > maxArgumentsPerCF) {
if (argumentsBasedCfg.getArguments().size() > maxArgumentsPerCF) {
throw new DataValidationException("Calculated field arguments limit reached!");
}
}
private void validateArgumentNames(CalculatedField calculatedField) {
if (calculatedField.getConfiguration().getArguments().containsKey("ctx")) {
throw new DataValidationException("Argument name 'ctx' is reserved and cannot be used.");
private void validateCalculatedFieldConfiguration(CalculatedField calculatedField) {
wrapAsDataValidation(calculatedField.getConfiguration()::validate);
}
private void validateSchedulingConfiguration(TenantId tenantId, CalculatedField calculatedField) {
if (!(calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledUpdateCfg)
|| !scheduledUpdateCfg.isScheduledUpdateEnabled()) {
return;
}
long minAllowedScheduledUpdateInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF);
wrapAsDataValidation(() -> scheduledUpdateCfg.validate(minAllowedScheduledUpdateInterval));
}
private void validateRelationQuerySourceArguments(TenantId tenantId, CalculatedField calculatedField) {
if (!(calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argumentsBasedCfg)) {
return;
}
Map<String, RelationQueryDynamicSourceConfiguration> relationQueryBasedArguments = argumentsBasedCfg.getArguments().entrySet()
.stream()
.filter(entry -> entry.getValue().hasDynamicSource())
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (RelationQueryDynamicSourceConfiguration) entry.getValue().getRefDynamicSourceConfiguration()));
if (relationQueryBasedArguments.isEmpty()) {
return;
}
int maxRelationLevel = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelationLevelPerCfArgument);
relationQueryBasedArguments.forEach((argumentName, relationQueryDynamicSourceConfiguration) ->
wrapAsDataValidation(() -> relationQueryDynamicSourceConfiguration.validateMaxRelationLevel(argumentName, maxRelationLevel)));
}
private static void wrapAsDataValidation(Runnable validation) {
try {
validation.run();
} catch (IllegalArgumentException e) {
throw new DataValidationException(e.getMessage(), e);
}
}

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

@ -31,18 +31,24 @@ import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfig
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.id.CalculatedFieldId;
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
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;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import java.util.Map;
import java.util.UUID;
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.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@DaoSqlTest
public class CalculatedFieldServiceTest extends AbstractServiceTest {
@ -51,6 +57,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
private CalculatedFieldService calculatedFieldService;
@Autowired
private DeviceService deviceService;
@Autowired
private TbTenantProfileCache tbTenantProfileCache;
private ListeningExecutorService executor;
@ -90,6 +98,182 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest {
calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId());
}
@Test
public void testSaveGeofencingCalculatedField_shouldNotChangeScheduledInterval() {
// Arrange a device
Device device = createTestDevice();
// Build a valid Geofencing configuration
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — no DYNAMIC configuration, so no scheduling even if the scheduled interval is set
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
zoneGroupConfiguration.setRefEntityId(device.getId());
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
// Set a scheduled interval to some value
cfg.setScheduledUpdateInterval(600);
// Create & save Calculated Field
CalculatedField cf = new CalculatedField();
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF clamp test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
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 = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval();
boolean scheduledUpdateEnabled = geofencingCalculatedFieldConfiguration.isScheduledUpdateEnabled();
assertThat(savedInterval).isEqualTo(600);
assertThat(scheduledUpdateEnabled).isFalse();
calculatedFieldService.deleteCalculatedField(tenantId, saved.getId());
}
@Test
public void testSaveGeofencingCalculatedField_shouldThrowWhenScheduledIntervalIsLessThanMinAllowedIntervalInTenantProfile() {
// Arrange a device
Device device = createTestDevice();
// Build a valid Geofencing configuration
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(1);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
// Enable scheduling with an interval below tenant min
cfg.setScheduledUpdateInterval(600);
// Create & save Calculated Field
CalculatedField cf = new CalculatedField();
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF clamp test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
assertThatThrownBy(() -> calculatedFieldService.save(cf))
.isInstanceOf(DataValidationException.class)
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasMessageStartingWith("Scheduled update interval is less than configured " +
"minimum allowed interval in tenant profile: ");
}
@Test
public void testSaveGeofencingCalculatedField_shouldThrowWhenRelationLevelIsGreaterThanMaxAllowedRelationLevelInTenantProfile() {
// Arrange a device
Device device = createTestDevice();
// Build a valid Geofencing configuration
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(Integer.MAX_VALUE);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
// Create & save Calculated Field
CalculatedField cf = new CalculatedField();
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF clamp test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
assertThatThrownBy(() -> calculatedFieldService.save(cf))
.isInstanceOf(DataValidationException.class)
.hasCauseInstanceOf(IllegalArgumentException.class)
.hasMessageStartingWith("Max relation level is greater than configured maximum allowed relation level in tenant profile");
}
@Test
public void testSaveGeofencingCalculatedField_shouldUseScheduledIntervalFromConfig() {
// Arrange a device
Device device = createTestDevice();
// Build a valid Geofencing configuration
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
// Coordinates: TS_LATEST, no dynamic source
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled
ZoneGroupConfiguration zoneGroupConfiguration = new ZoneGroupConfiguration( "allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
var dynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration();
dynamicSourceConfiguration.setDirection(EntitySearchDirection.FROM);
dynamicSourceConfiguration.setMaxLevel(1);
dynamicSourceConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE);
zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration);
cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration));
// Get tenant profile min.
int min = tbTenantProfileCache.get(tenantId)
.getDefaultProfileConfiguration()
.getMinAllowedScheduledUpdateIntervalInSecForCF();
// Enable scheduling with an interval greater than tenant min
int valueFromConfig = min + 100;
cfg.setScheduledUpdateInterval(valueFromConfig);
// Create & save Calculated Field
CalculatedField cf = new CalculatedField();
cf.setTenantId(tenantId);
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("GF no clamp test");
cf.setConfigurationVersion(0);
cf.setConfiguration(cfg);
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 = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval();
assertThat(savedInterval).isEqualTo(valueFromConfig);
calculatedFieldService.deleteCalculatedField(tenantId, saved.getId());
}
@Test
public void testSaveCalculatedFieldWithExistingName() {
Device device = createTestDevice();

19
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestRestClient.java

@ -16,6 +16,7 @@
package org.thingsboard.server.msa;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.restassured.RestAssured;
import io.restassured.common.mapper.TypeRef;
@ -231,9 +232,9 @@ public class TestRestClient {
.statusCode(anyOf(is(HTTP_OK), is(HTTP_NOT_FOUND)));
}
public ValidatableResponse postTelemetryAttribute(EntityId entityId, String scope, JsonNode attribute) {
public ValidatableResponse postTelemetryAttribute(EntityId entityId, AttributeScope scope, JsonNode attribute) {
return given().spec(requestSpec).body(attribute)
.post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityId.getEntityType(), entityId.getId(), scope)
.post("/api/plugins/telemetry/{entityType}/{entityId}/attributes/{scope}", entityId.getEntityType(), entityId.getId(), scope.name())
.then()
.statusCode(HTTP_OK);
}
@ -256,13 +257,13 @@ public class TestRestClient {
.as(JsonNode.class);
}
public JsonNode getAttributes(EntityId entityId, AttributeScope scope, String keys) {
public ArrayNode getAttributes(EntityId entityId, AttributeScope scope, String keys) {
return given().spec(requestSpec)
.get("/api/plugins/telemetry/{entityType}/{entityId}/values/attributes/{scope}?keys={keys}", entityId.getEntityType(), entityId.getId(), scope, keys)
.then()
.statusCode(HTTP_OK)
.extract()
.as(JsonNode.class);
.as(ArrayNode.class);
}
public JsonNode getLatestTelemetry(EntityId entityId) {
@ -367,6 +368,16 @@ public class TestRestClient {
});
}
public EntityRelation postEntityRelation(EntityRelation entityRelation) {
return given().spec(requestSpec)
.body(entityRelation)
.post("/api/v2/relation")
.then()
.statusCode(HTTP_OK)
.extract()
.as(EntityRelation.class);
}
public JsonNode postServerSideRpc(DeviceId deviceId, JsonNode serverRpcPayload) {
return given().spec(requestSpec)
.body(serverRpcPayload)

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

@ -16,14 +16,13 @@
package org.thingsboard.server.msa.cf;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils;
import org.testng.annotations.AfterClass;
import org.testng.annotations.BeforeClass;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.asset.Asset;
@ -34,8 +33,12 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
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.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.GeofencingCalculatedFieldConfiguration;
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;
@ -45,14 +48,19 @@ import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.msa.AbstractContainerTest;
import org.thingsboard.server.msa.ui.utils.EntityPrototypes;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultAssetProfile;
import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultDeviceProfile;
import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultTenantAdmin;
@ -65,17 +73,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;
@ -98,13 +106,13 @@ public class CalculatedFieldTest extends AbstractContainerTest {
asset = testRestClient.postAsset(createAsset("Asset 1", assetProfileId));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}"));
testRestClient.postTelemetryAttribute(device.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}"));
testRestClient.postTelemetryAttribute(device.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"deviceTemperature\":40}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.32}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":72.86}"));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperatureInF\":73.58}"));
testRestClient.postTelemetryAttribute(asset.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}"));
testRestClient.postTelemetryAttribute(asset.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1035}"));
}
@BeforeMethod
@ -146,9 +154,10 @@ public class CalculatedFieldTest extends AbstractContainerTest {
testRestClient.getAndSetUserToken(tenantAdminId);
CalculatedField savedCalculatedField = createSimpleCalculatedField();
assertThat(savedCalculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration).isTrue();
Argument savedArgument = savedCalculatedField.getConfiguration().getArguments().get("T");
savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
Argument savedArgument = ((SimpleCalculatedFieldConfiguration) savedCalculatedField.getConfiguration()).getArguments().get("T");
savedArgument.setRefEntityKey(new ReferencedEntityKey("deviceTemperature", ArgumentType.ATTRIBUTE, SERVER_SCOPE));
testRestClient.postCalculatedField(savedCalculatedField);
await().alias("update CF argument -> perform calculation with new argument").atMost(TIMEOUT, TimeUnit.SECONDS)
@ -172,14 +181,14 @@ public class CalculatedFieldTest extends AbstractContainerTest {
Output savedOutput = savedCalculatedField.getConfiguration().getOutput();
savedOutput.setType(OutputType.ATTRIBUTES);
savedOutput.setScope(AttributeScope.SERVER_SCOPE);
savedOutput.setScope(SERVER_SCOPE);
savedOutput.setName("temperatureF");
testRestClient.postCalculatedField(savedCalculatedField);
await().alias("update CF output -> perform calculation with updated output").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
JsonNode temperatureF = testRestClient.getAttributes(device.getId(), AttributeScope.SERVER_SCOPE, "temperatureF");
ArrayNode temperatureF = testRestClient.getAttributes(device.getId(), SERVER_SCOPE, "temperatureF");
assertThat(temperatureF).isNotNull();
assertThat(temperatureF.get(0)).isNotNull();
assertThat(temperatureF.get(0).get("value").asText()).isEqualTo("77.0");
@ -194,9 +203,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)
@ -305,7 +315,7 @@ public class CalculatedFieldTest extends AbstractContainerTest {
assertThat(airDensity.get("airDensity").get(0).get("value").asText()).isEqualTo("1.05");
});
testRestClient.postTelemetryAttribute(asset.getId(), DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1531}"));
testRestClient.postTelemetryAttribute(asset.getId(), SERVER_SCOPE, JacksonUtil.toJsonNode("{\"altitude\":1531}"));
await().alias("create CF -> update telemetry for common entity").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
@ -319,6 +329,100 @@ public class CalculatedFieldTest extends AbstractContainerTest {
testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId());
}
@Test
public void testPerformSerialsOfCalculationsForGeofencingType() {
// login tenant admin
testRestClient.getAndSetUserToken(tenantAdminId);
// Device and initial coords (inside Allowed, outside Restricted)
String deviceToken = "geoDeviceTokenA";
Device device = testRestClient.postDevice(deviceToken, createDevice("GF Device", deviceProfileId));
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}"));
// Create zones
Asset allowed = testRestClient.postAsset(createAsset("Allowed Zone", null));
testRestClient.postTelemetryAttribute(allowed.getId(), SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":[[50.472000,30.504000],[50.472000,30.506000],[50.474000,30.506000],[50.474000,30.504000]]}"));
Asset restricted = testRestClient.postAsset(createAsset("Restricted Zone", null));
testRestClient.postTelemetryAttribute(restricted.getId(), SERVER_SCOPE,
JacksonUtil.toJsonNode("{\"zone\":[[50.475000,30.510000],[50.475000,30.512000],[50.477000,30.512000],[50.477000,30.510000]]}"));
// Relations FROM device
testRestClient.postEntityRelation(new EntityRelation(device.getId(), allowed.getId(), "AllowedZone"));
testRestClient.postEntityRelation(new EntityRelation(device.getId(), restricted.getId(), "RestrictedZone"));
// Build CF: GEOFENCING -> attributes output
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.GEOFENCING);
cf.setName("Geofencing CF");
cf.setDebugSettings(DebugSettings.off());
GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude");
cfg.setEntityCoordinates(entityCoordinates);
// Dynamic groups via relations
ZoneGroupConfiguration allowedZoneGroupConfiguration = new ZoneGroupConfiguration("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("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(Map.of("allowedZones", allowedZoneGroupConfiguration, "restrictedZones", restrictedZoneGroupConfiguration));
Output out = new Output();
out.setType(OutputType.ATTRIBUTES);
out.setScope(SERVER_SCOPE);
cfg.setOutput(out);
cf.setConfiguration(cfg);
CalculatedField saved = testRestClient.postCalculatedField(cf);
// Initial ENTERED/INSIDE and OUTSIDE
await().alias("initial geofencing evaluation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = testRestClient.getAttributes(device.getId(), SERVER_SCOPE,
"allowedZonesEvent,allowedZonesStatus,restrictedZonesStatus");
assertThat(attrs).isNotNull().hasSize(3);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "ENTERED")
.containsEntry("allowedZonesStatus", "INSIDE")
.containsEntry("restrictedZonesStatus", "OUTSIDE");
});
// Move device into Restricted zone -> expect LEFT/ENTERED and statuses flipped
testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}"));
await().alias("transition after movement").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs = testRestClient.getAttributes(device.getId(), SERVER_SCOPE,
"allowedZonesEvent,allowedZonesStatus,restrictedZonesEvent,restrictedZonesStatus");
assertThat(attrs).isNotNull().hasSize(4);
Map<String, String> m = kv(attrs);
assertThat(m).containsEntry("allowedZonesEvent", "LEFT")
.containsEntry("allowedZonesStatus", "OUTSIDE")
.containsEntry("restrictedZonesEvent", "ENTERED")
.containsEntry("restrictedZonesStatus", "INSIDE");
});
testRestClient.deleteCalculatedFieldIfExists(saved.getId());
}
private CalculatedField createSimpleCalculatedField() {
return createSimpleCalculatedField(device.getId());
}
@ -366,7 +470,7 @@ public class CalculatedFieldTest extends AbstractContainerTest {
Argument argument1 = new Argument();
argument1.setRefEntityId(refEntityId);
ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("altitude", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE);
ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("altitude", ArgumentType.ATTRIBUTE, SERVER_SCOPE);
argument1.setRefEntityKey(refEntityKey1);
Argument argument2 = new Argument();
ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("temperatureInF", ArgumentType.TS_ROLLING, null);
@ -406,4 +510,12 @@ public class CalculatedFieldTest extends AbstractContainerTest {
return asset;
}
private static Map<String, String> kv(ArrayNode attrs) {
Map<String, String> m = new HashMap<>();
for (JsonNode n : attrs) {
m.put(n.get("key").asText(), n.get("value").asText());
}
return m;
}
}

3
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java

@ -35,8 +35,7 @@ import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.common.data.AttributeScope.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype;
@DisableUIListeners

4
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java

@ -81,7 +81,7 @@ import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.fail;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.common.data.AttributeScope.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype;
@DisableUIListeners
@ -577,7 +577,7 @@ public class MqttClientTest extends AbstractContainerTest {
Awaitility
.await()
.alias("Check device disconnect.")
.atMost(TIMEOUT*timeoutMultiplier, TimeUnit.SECONDS)
.atMost(TIMEOUT * timeoutMultiplier, TimeUnit.SECONDS)
.until(() -> !returnCodeByteValue.isEmpty());
assertThat(returnCodeByteValueSecondClient).isEmpty();

2
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java

@ -64,7 +64,7 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.common.data.AttributeScope.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype;
@DisableUIListeners

2
ui-ngx/src/app/core/auth/auth.models.ts

@ -31,6 +31,8 @@ export interface SysParamsState {
maxDebugModeDurationMinutes: number;
maxDataPointsPerRollingArg: number;
maxArgumentsPerCF: number;
minAllowedScheduledUpdateIntervalInSecForCF: number;
maxRelationLevelPerCfArgument: number;
ruleChainDebugPerTenantLimitsConfiguration?: string;
calculatedFieldDebugPerTenantLimitsConfiguration?: string;
trendzSettings: TrendzSettings;

2
ui-ngx/src/app/core/auth/auth.reducer.ts

@ -33,6 +33,8 @@ const emptyUserAuthState: AuthPayload = {
mobileQrEnabled: false,
maxResourceSize: 0,
maxArgumentsPerCF: 0,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
maxRelationLevelPerCfArgument: 0,
maxDataPointsPerRollingArg: 0,
maxDebugModeDurationMinutes: 0,
userSettings: initialUserSettings,

32
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts

@ -113,16 +113,16 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
expressionColumn.sortable = false;
expressionColumn.cellContentFunction = entity => {
const expressionLabel = this.getExpressionLabel(entity);
return expressionLabel.length < 45 ? expressionLabel : `<span style="display: inline-block; width: 45ch">${expressionLabel.substring(0, 44)}…</span>`;
return expressionLabel?.length < 45 ? expressionLabel : `<span style="display: inline-block; width: 45ch">${expressionLabel.substring(0, 44)}…</span>`;
}
expressionColumn.cellTooltipFunction = entity => {
const expressionLabel = this.getExpressionLabel(entity);
return expressionLabel.length < 45 ? null : expressionLabel
return expressionLabel?.length < 45 ? null : expressionLabel
};
this.columns.push(new DateEntityTableColumn<CalculatedField>('createdTime', 'common.created-time', this.datePipe, '150px'));
this.columns.push(new EntityTableColumn<CalculatedField>('name', 'common.name', '33%'));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '50px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(new EntityTableColumn<CalculatedField>('type', 'common.type', '70px', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type))));
this.columns.push(expressionColumn);
this.cellActionDescriptors.push(
@ -159,7 +159,7 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
if (entity.type === CalculatedFieldType.SCRIPT) {
return 'function calculate(ctx, ' + Object.keys(entity.configuration.arguments).join(', ') + ')';
} else {
return entity.configuration.expression;
return entity.configuration?.expression ?? '';
}
}
@ -257,13 +257,23 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
}
private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField {
calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const arg = calculatedField.configuration.arguments[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
if (calculatedField.type === CalculatedFieldType.GEOFENCING) {
calculatedField.configuration.zoneGroups = Object.keys(calculatedField.configuration.zoneGroups).reduce((acc, key) => {
const arg = calculatedField.configuration.zoneGroups[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
} else {
calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => {
const arg = calculatedField.configuration.arguments[key];
acc[key] = arg.refEntityId?.entityType === ArgumentEntityType.Tenant
? { ...arg, refEntityId: { id: this.tenantId, entityType: ArgumentEntityType.Tenant } }
: arg;
return acc;
}, {});
}
return calculatedField;
}

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.html

@ -59,7 +59,7 @@
<mat-cell *matCellDef="let argument" class="w-1/4 xs:hidden">
<div tbTruncateWithTooltip>
@if (argument.refEntityId?.id && argument.refEntityId?.entityType !== ArgumentEntityType.Tenant) {
<a aria-label="Open entity details page"
<a [attr.aria-label]="'calculated-fields.open-details-page' | translate"
[routerLink]="getEntityDetailsPageURL(argument.refEntityId.id, argument.refEntityId.entityType)">
{{ entityNameMap.get(argument.refEntityId.id) ?? '' }}
</a>

172
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html

@ -63,76 +63,116 @@
</mat-form-field>
</div>
<ng-container [formGroup]="configFormGroup">
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table
formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"
[calculatedFieldType]="fieldFormGroup.get('type').value"
/>
</div>
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }}
@if (fieldFormGroup.get('type').value !== CalculatedFieldType.GEOFENCING) {
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required">{{ 'calculated-fields.arguments' | translate }}</div>
<tb-calculated-field-arguments-table formControlName="arguments"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"
[calculatedFieldType]="fieldFormGroup.get('type').value"/>
</div>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SIMPLE">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'" required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
<div class="tb-form-panel no-gap">
<div class="tb-form-panel-title tb-required">
{{ (fieldFormGroup.get('type').value === CalculatedFieldType.SIMPLE ? 'calculated-fields.expression' : 'calculated-fields.type.script' ) | translate }}
</div>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SCRIPT">
<tb-js-func
required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn"
>
<div toolbarPrefixButton class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="configFormGroup.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
<mat-form-field class="mt-3" appearance="outline" subscriptSizing="dynamic" [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SIMPLE">
<input matInput formControlName="expressionSIMPLE" maxlength="255" [placeholder]="'(temperature - 32) / 1.8'" required>
<div matSuffix
class="pr-2"
[tb-help-popup]="'math/math-methods_fn'"
tb-help-popup-placement="left"
[tb-help-popup-style]="{maxWidth: '970px'}">
</div>
@if (configFormGroup.get('expressionSIMPLE').errors && configFormGroup.get('expressionSIMPLE').touched) {
<mat-error>
@if (configFormGroup.get('expressionSIMPLE').hasError('required')) {
{{ 'calculated-fields.hint.expression-required' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('pattern')) {
{{ 'calculated-fields.hint.expression-invalid' | translate }}
} @else if (configFormGroup.get('expressionSIMPLE').hasError('maxLength')) {
{{ 'calculated-fields.hint.expression-max-length' | translate }}
}
</mat-error>
} @else {
<mat-hint>{{ 'calculated-fields.hint.expression' | translate }}</mat-hint>
}
</mat-form-field>
<div [class.hidden]="fieldFormGroup.get('type').value !== CalculatedFieldType.SCRIPT">
<tb-js-func required
formControlName="expressionSCRIPT"
functionName="calculate"
[functionArgs]="functionArgs$ | async"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="argumentsHighlightRules$ | async"
[editorCompleter]="argumentsEditorCompleter$ | async"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/expression_fn">
<div toolbarPrefixButton class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}</div>
<button toolbarSuffixButton
mat-icon-button
matTooltip="{{ 'calculated-fields.test-script-function' | translate }}"
matTooltipPosition="above"
class="tb-mat-32"
[disabled]="configFormGroup.get('arguments').invalid"
(click)="onTestScript()">
<mat-icon class="material-icons" color="primary">bug_report</mat-icon>
</button>
</tb-js-func>
<div>
<button mat-button mat-raised-button color="primary"
type="button"
(click)="onTestScript()"
[disabled]="configFormGroup.get('arguments').invalid">
{{ 'calculated-fields.test-script-function' | translate }}
</button>
</div>
</div>
</div>
</div>
} @else {
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.entity-coordinates' | translate }}">
{{ 'calculated-fields.entity-coordinates' | translate }}
</div>
<div class="flex items-start gap-3" [formGroup]="coordinatesFormGroup">
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.latitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.latitude-time-series-key-required' | translate }}"
formControlName="latitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
<tb-entity-key-autocomplete class="flex-1"
placeholder="{{ 'calculated-fields.longitude-time-series-key' | translate }}"
requiredText="{{ 'calculated-fields.longitude-time-series-key-required' | translate }}"
formControlName="longitudeKeyName"
[dataKeyType]="DataKeyType.timeseries"
[entityFilter]="currentEntityFilter"/>
</div>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title tb-required" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.geofencing-zone-groups' | translate }}">
{{ 'calculated-fields.geofencing-zone-groups' | translate }}
</div>
<tb-calculated-field-geofencing-zone-groups-table formControlName="zoneGroups"
[entityId]="data.entityId"
[tenantId]="data.tenantId"
[entityName]="data.entityName"/>
<div class="tb-form-row space-between flex-1 columns-xs" [class.!hidden]="!isRelatedEntity">
<div tb-hint-tooltip-icon="{{'calculated-fields.hint.zone-group-refresh-interval' | translate}}">{{ 'calculated-fields.zone-group-refresh-interval' | translate }}</div>
<div class="flex flex-row items-center justify-start gap-2">
<tb-time-unit-input required
inlineField
requiredText="{{ 'calculated-fields.hint.zone-group-refresh-interval-required' | translate }}"
minErrorText="{{ 'calculated-fields.hint.zone-group-refresh-interval-min' | translate }}"
[minTime]="minAllowedScheduledUpdateIntervalInSecForCF"
formControlName="scheduledUpdateInterval">
</tb-time-unit-input>
</div>
</div>
</div>
}
<div class="tb-form-panel" [formGroup]="outputFormGroup">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex items-center gap-3">

102
ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts

@ -22,19 +22,22 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { DialogComponent } from '@shared/components/dialog.component';
import {
ArgumentEntityType,
CalculatedField,
CalculatedFieldConfiguration,
calculatedFieldDefaultScript,
CalculatedFieldGeofencing,
CalculatedFieldTestScriptFn,
CalculatedFieldType,
CalculatedFieldTypeTranslations,
getCalculatedFieldArgumentsEditorCompleter,
getCalculatedFieldArgumentsHighlights,
getCalculatedFieldCurrentEntityFilter,
OutputType,
OutputTypeTranslations
} from '@shared/models/calculated-field.models';
import { digitsRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { AttributeScope, DataKeyType } from '@shared/models/telemetry/telemetry.models';
import { EntityType } from '@shared/models/entity-type.models';
import { map, startWith, switchMap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@ -43,6 +46,8 @@ import { CalculatedFieldsService } from '@core/http/calculated-fields.service';
import { Observable } from 'rxjs';
import { EntityId } from '@shared/models/id/entity-id';
import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model';
import { EntityFilter } from '@shared/models/query/query.models';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
export interface CalculatedFieldDialogData {
value?: CalculatedField;
@ -63,12 +68,20 @@ export interface CalculatedFieldDialogData {
})
export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFieldDialogComponent, CalculatedField> {
readonly minAllowedScheduledUpdateIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedScheduledUpdateIntervalInSecForCF;
fieldFormGroup = this.fb.group({
name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
type: [CalculatedFieldType.SIMPLE],
debugSettings: [],
configuration: this.fb.group({
entityCoordinates: this.fb.group({
latitudeKeyName: [null, [Validators.required]],
longitudeKeyName: [null, [Validators.required]],
}),
arguments: this.fb.control({}),
zoneGroups: this.fb.control({}),
scheduledUpdateInterval: [this.minAllowedScheduledUpdateIntervalInSecForCF],
expressionSIMPLE: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]],
expressionSCRIPT: [calculatedFieldDefaultScript],
output: this.fb.group({
@ -104,6 +117,10 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
action: () => this.data.additionalDebugActionConfig.action({ id: this.data.value.id, ...this.fromGroupValue }),
} : null;
currentEntityFilter: EntityFilter;
isRelatedEntity: boolean;
readonly OutputTypeTranslations = OutputTypeTranslations;
readonly OutputType = OutputType;
readonly AttributeScope = AttributeScope;
@ -113,6 +130,7 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
readonly fieldTypes = Object.values(CalculatedFieldType) as CalculatedFieldType[];
readonly outputTypes = Object.values(OutputType) as OutputType[];
readonly CalculatedFieldTypeTranslations = CalculatedFieldTypeTranslations;
readonly DataKeyType = DataKeyType;
constructor(protected store: Store<AppState>,
protected router: Router,
@ -125,6 +143,8 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
this.observeIsLoading();
this.applyDialogData();
this.observeTypeChanges();
this.observeZoneChanges();
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.data.entityName, this.data.entityId);
}
get configFormGroup(): FormGroup {
@ -135,19 +155,34 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
return this.fieldFormGroup.get('configuration').get('output') as FormGroup;
}
get coordinatesFormGroup(): FormGroup {
return this.fieldFormGroup.get('configuration').get('entityCoordinates') as FormGroup;
}
get fromGroupValue(): CalculatedField {
const { configuration, type, name, ...rest } = this.fieldFormGroup.value;
const { expressionSIMPLE, expressionSCRIPT, output, ...restConfig } = configuration;
return {
configuration: {
...restConfig,
type, expression: configuration['expression'+type].trim(),
output: { ...output, name: output.name?.trim() ?? '' }
},
let cf: CalculatedField = {
name: name.trim(),
type,
...rest,
...rest
} as CalculatedField;
if (type !== CalculatedFieldType.GEOFENCING) {
cf.configuration = {
...restConfig,
type,
expression: configuration['expression'+type].trim(),
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
} else {
cf.configuration = {
...restConfig,
type,
output: { ...output, name: output.name?.trim() ?? '' }
} as CalculatedFieldConfiguration;
delete cf.configuration.arguments;
}
return cf;
}
cancel(): void {
@ -204,6 +239,19 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
.subscribe(type => this.toggleKeyByCalculatedFieldType(type));
}
private observeZoneChanges(): void {
this.configFormGroup.get('zoneGroups').valueChanges
.pipe(takeUntilDestroyed())
.subscribe((zoneGroups: CalculatedFieldGeofencing) =>
this.checkRelatedEntity(zoneGroups)
);
this.checkRelatedEntity(this.configFormGroup.get('zoneGroups').value);
}
private checkRelatedEntity(zoneGroups: CalculatedFieldGeofencing) {
this.isRelatedEntity = Object.values(zoneGroups).some(zone => zone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery);
}
private toggleScopeByOutputType(type: OutputType): void {
if (type === OutputType.Attribute) {
this.outputFormGroup.get('scope').enable({emitEvent: false});
@ -222,20 +270,36 @@ export class CalculatedFieldDialogComponent extends DialogComponent<CalculatedFi
}
private toggleKeyByCalculatedFieldType(type: CalculatedFieldType): void {
if (type === CalculatedFieldType.SIMPLE) {
this.outputFormGroup.get('name').enable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
if (this.outputFormGroup.get('type').value === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
if (type === CalculatedFieldType.GEOFENCING) {
this.configFormGroup.get('entityCoordinates').enable({emitEvent: false});
this.configFormGroup.get('zoneGroups').enable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').enable({emitEvent: false});
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
this.configFormGroup.get('arguments').disable({emitEvent: false});
} else {
this.configFormGroup.get('entityCoordinates').disable({emitEvent: false});
this.configFormGroup.get('zoneGroups').disable({emitEvent: false});
this.configFormGroup.get('scheduledUpdateInterval').disable({emitEvent: false});
if (type === CalculatedFieldType.SIMPLE) {
this.outputFormGroup.get('name').enable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').enable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').disable({emitEvent: false});
if (this.outputFormGroup.get('type').value === OutputType.Attribute) {
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
} else {
this.configFormGroup.get('useLatestTs').enable({emitEvent: false});
}
} else {
this.outputFormGroup.get('name').disable({emitEvent: false});
this.configFormGroup.get('useLatestTs').disable({emitEvent: false});
this.configFormGroup.get('expressionSIMPLE').disable({emitEvent: false});
this.configFormGroup.get('expressionSCRIPT').enable({emitEvent: false});
}
}
}

146
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.html

@ -0,0 +1,146 @@
<!--
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.
-->
<div class="flex flex-col gap-3">
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText">
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear>
<ng-container [matColumnDef]="'name'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2">
<div tbTruncateWithTooltip>{{ 'common.name' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="argument-name-cell w-1/3 xs:w-1/2">
<div class="flex items-center">
<div tbTruncateWithTooltip class="flex-1">{{ geofenceZone.name }}</div>
<tb-copy-button class="copy-argument-name"
[copyText]="geofenceZone.name"
tooltipText="{{ 'calculated-fields.copy-zone-group-name' | translate }}"
tooltipPosition="above"
icon="content_copy"/>
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'entityType'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden">
{{ 'entity.entity-type' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/5 xs:hidden">
<div tbTruncateWithTooltip>
@if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) {
{{ 'calculated-fields.argument-current-tenant' | translate }}
} @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) {
{{ 'calculated-fields.argument-relation-query' | translate }}
} @else if (geofenceZone.refEntityId?.id) {
{{ entityTypeTranslations.get(geofenceZone.refEntityId.entityType).type | translate }}
} @else {
{{ 'calculated-fields.argument-current' | translate }}
}
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'target'">
<mat-header-cell *matHeaderCellDef class="w-1/4 xs:hidden">
{{ 'calculated-fields.target-zone' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 xs:hidden">
<div tbTruncateWithTooltip>
@if (geofenceZone.refEntityId?.id && geofenceZone.refEntityId?.entityType !== ArgumentEntityType.Tenant) {
<a [attr.aria-label]="'calculated-fields.open-details-page' | translate"
[routerLink]="getEntityDetailsPageURL(geofenceZone.refEntityId.id, geofenceZone.refEntityId.entityType)">
{{ entityNameMap.get(geofenceZone.refEntityId.id) ?? '' }}
</a>
}
</div>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'key'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 xs:w-1/3">
{{ 'calculated-fields.perimeter-key' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 xs:w-1/3">
<mat-chip class="tb-chip-row-ellipsis">
<div tbTruncateWithTooltip class="key-text">{{ geofenceZone.perimeterKeyName }}</div>
</mat-chip>
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'reportStrategy'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 lt-md:hidden">
{{ 'calculated-fields.report-strategy' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 lt-md:hidden">
<div tbTruncateWithTooltip>{{ GeofencingReportStrategyTranslations.get(geofenceZone.reportStrategy) | translate }}</div>
</mat-cell>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/>
<mat-cell *matCellDef="let geofenceZone;">
<div class="tb-form-table-row-cell-buttons flex w-20 min-w-20">
<button type="button"
mat-icon-button
#button
(click)="manageZone($event, button, geofenceZone)"
[matTooltip]="'action.edit' | translate"
matTooltipPosition="above">
<mat-icon [matBadgeHidden]="geofenceZone.refEntityId?.id !== NULL_UUID"
matBadgeColor="warn"
matBadgeSize="small"
matBadge="*">
edit
</mat-icon>
</button>
<button type="button"
mat-icon-button
(click)="onDelete($event, geofenceZone)"
[matTooltip]="'action.delete' | translate"
matTooltipPosition="above">
<mat-icon>delete</mat-icon>
</button>
</div>
</mat-cell>
</ng-container>
<mat-header-row class="mat-row-select"
*matHeaderRowDef="['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-header-row>
<mat-row *matRowDef="let argument; columns: ['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-row>
</table>
<div [class.!hidden]="(dataSource.isEmpty() | async) === false"
class="tb-prompt flex flex-1 items-end justify-center">
{{ 'calculated-fields.no-zone-configured' | translate }}
</div>
@if (errorText) {
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/>
}
</div>
<div class="flex h-9 justify-between">
<button type="button"
mat-stroked-button
color="primary"
#button
(click)="manageZone($event, button)"
[disabled]="maxArgumentsPerCF > 0 && zoneGroupsFormArray.length >= maxArgumentsPerCF">
{{ 'calculated-fields.add-zone-group' | translate }}
</button>
@if (maxArgumentsPerCF && zoneGroupsFormArray.length >= maxArgumentsPerCF) {
<div class="tb-form-hint tb-primary-fill max-args-warning flex items-center gap-2">
<mat-icon>warning</mat-icon>
<span>{{ 'calculated-fields.hint.max-geofencing-zone' | translate }}</span>
</div>
}
</div>
</div>

76
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.scss

@ -0,0 +1,76 @@
/**
* 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.
*/
:host {
.arguments-table {
min-height: 108px;
&-with-error {
min-height: 150px;
}
.mat-mdc-table {
table-layout: fixed;
}
.key-text {
font-size: 13px;
}
.copy-argument-name {
visibility: hidden;
transition: visibility 0.1s;
}
.argument-name-cell:hover {
.copy-argument-name {
visibility: visible;
}
}
}
.max-args-warning {
.mat-icon {
color: #FAA405;
}
}
.tb-form-table-row-cell-buttons {
--mat-badge-legacy-small-size-container-size: 8px;
--mat-badge-small-size-container-overlap-offset: -5px;
--mat-badge-small-size-text-size: 0;
}
}
:host ::ng-deep {
.arguments-table:not(.arguments-table-with-error) {
.mdc-data-table__row:last-child .mat-mdc-cell {
border-bottom: none;
}
}
.arguments-table {
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header {
padding: 0 28px 0 0;
}
}
.copy-argument-name {
.mat-icon {
font-size: 16px;
padding: 4px;
}
}
}

307
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-zone-grups-table/calculated-field-geofencing-zone-groups-table.component.ts

@ -0,0 +1,307 @@
///
/// 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.
///
import {
AfterViewInit,
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
Input,
Renderer2,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
} from '@angular/forms';
import {
ArgumentEntityType,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingValue,
CalculatedFieldType,
GeofencingReportStrategyTranslations,
} from '@shared/models/calculated-field.models';
import { MatButton } from '@angular/material/button';
import { TbPopoverService } from '@shared/components/popover.service';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityId } from '@shared/models/id/entity-id';
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models';
import { getEntityDetailsPageURL, isEqual } from '@core/utils';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract';
import { EntityService } from '@core/http/entity.service';
import { MatSort } from '@angular/material/sort';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { forkJoin, Observable } from 'rxjs';
import { NULL_UUID } from '@shared/models/id/has-uuid';
import { BaseData } from '@shared/models/base-data';
import {
CalculatedFieldGeofencingZoneGroupsPanelComponent
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component';
@Component({
selector: 'tb-calculated-field-geofencing-zone-groups-table',
templateUrl: './calculated-field-geofencing-zone-groups-table.component.html',
styleUrls: [`calculated-field-geofencing-zone-groups-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent),
multi: true
}
],
})
export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
@Input() entityId: EntityId;
@Input() tenantId: string;
@Input() entityName: string;
@ViewChild(MatSort, { static: true }) sort: MatSort;
errorText = '';
zoneGroupsFormArray = this.fb.array<CalculatedFieldGeofencingValue>([]);
entityNameMap = new Map<string, string>();
sortOrder = { direction: 'asc', property: '' };
dataSource = new CalculatedFieldZoneDatasource();
readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations;
readonly entityTypeTranslations = entityTypeTranslations;
readonly ArgumentEntityType = ArgumentEntityType;
readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2;
readonly NULL_UUID = NULL_UUID;
private popoverComponent: TbPopoverComponent<CalculatedFieldGeofencingZoneGroupsPanelComponent>;
private propagateChange: (zonesObj: Record<string, CalculatedFieldGeofencing>) => void = () => {};
constructor(
private fb: FormBuilder,
private popoverService: TbPopoverService,
private viewContainerRef: ViewContainerRef,
private cd: ChangeDetectorRef,
private renderer: Renderer2,
private entityService: EntityService,
private destroyRef: DestroyRef,
private store: Store<AppState>
) {
this.zoneGroupsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => {
this.updateDataSource(value);
this.propagateChange(this.getZonesObject(value));
});
}
ngAfterViewInit(): void {
this.sort.sortChange.asObservable().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.sortOrder.property = this.sort.active;
this.sortOrder.direction = this.sort.direction;
this.updateDataSource(this.zoneGroupsFormArray.value);
});
}
registerOnChange(fn: (zonesObj: Record<string, CalculatedFieldGeofencing>) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_): void {}
validate(): ValidationErrors | null {
this.updateErrorText();
return this.errorText ? { zonesFormArray: false } : null;
}
onDelete($event: Event, zone: CalculatedFieldGeofencingValue): void {
$event.stopPropagation();
const index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone));
this.zoneGroupsFormArray.removeAt(index);
this.zoneGroupsFormArray.markAsDirty();
}
manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue): void {
$event?.stopPropagation();
if (this.popoverComponent && !this.popoverComponent.tbHidden) {
this.popoverComponent.hide();
}
const trigger = matButton._elementRef.nativeElement;
if (this.popoverService.hasPopover(trigger)) {
this.popoverService.hidePopover(trigger);
} else {
const index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone));
const isExists = index !== -1;
const ctx = {
index,
zone,
entityId: this.entityId,
calculatedFieldType: CalculatedFieldType.GEOFENCING,
buttonTitle: isExists ? 'action.apply' : 'action.add',
tenantId: this.tenantId,
entityName: this.entityName,
usedNames: this.zoneGroupsFormArray.value.map(({ name }) => name).filter(name => name !== zone.name),
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,
renderer: this.renderer,
componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent,
hostView: this.viewContainerRef,
preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'],
context: ctx,
isModal: true
});
this.popoverComponent.tbComponentRef.instance.geofencingDataApplied.subscribe(({ entityName, ...value }) => {
this.popoverComponent.hide();
if (entityName) {
this.entityNameMap.set(value.refEntityId.id, entityName);
}
if (isExists) {
this.zoneGroupsFormArray.at(index).setValue(value);
} else {
this.zoneGroupsFormArray.push(this.fb.control(value));
}
this.cd.markForCheck();
});
}
}
private updateDataSource(value: CalculatedFieldGeofencingValue[]): void {
const sortedValue = this.sortData(value);
this.dataSource.loadData(sortedValue);
}
private updateErrorText(): void {
if (this.zoneGroupsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) {
this.errorText = 'calculated-fields.hint.geofencing-entity-not-found';
} else if (!this.zoneGroupsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.geofencing-empty';
} else {
this.errorText = '';
}
}
private getZonesObject(value: CalculatedFieldGeofencingValue[]): Record<string, CalculatedFieldGeofencing> {
return value.reduce((acc, zoneValue) => {
const { name, ...zone } = zoneValue as CalculatedFieldGeofencingValue;
acc[name] = zone;
return acc;
}, {} as Record<string, CalculatedFieldGeofencing>);
}
writeValue(zonesObj: Record<string, CalculatedFieldGeofencing>): void {
this.zoneGroupsFormArray.clear();
this.populateZonesFormArray(zonesObj);
this.updateEntityNameMap(this.zoneGroupsFormArray.value);
}
getEntityDetailsPageURL(id: string, type: EntityType): string {
return getEntityDetailsPageURL(id, type);
}
private populateZonesFormArray(zonesObj: Record<string, CalculatedFieldGeofencing>): void {
Object.keys(zonesObj).forEach(key => {
const value: CalculatedFieldGeofencingValue = {
...zonesObj[key],
name: key
};
this.zoneGroupsFormArray.push(this.fb.control(value), { emitEvent: false });
});
this.zoneGroupsFormArray.updateValueAndValidity();
}
private updateEntityNameMap(values: CalculatedFieldGeofencingValue[]): void {
const entitiesByType = values.reduce((acc, { refEntityId = {}}) => {
if (refEntityId.id && refEntityId.entityType !== ArgumentEntityType.Tenant) {
const { id, entityType } = refEntityId as EntityId;
acc[entityType] = acc[entityType] ?? [];
acc[entityType].push(id);
}
return acc;
}, {} as Record<EntityType, string[]>);
const tasks = Object.entries(entitiesByType).map(([entityType, ids]) =>
this.entityService.getEntities(entityType as EntityType, ids)
);
if (!tasks.length) {
return;
}
this.fetchEntityNames(tasks, values);
}
private fetchEntityNames(tasks: Observable<BaseData<EntityId>[]>[], values: CalculatedFieldGeofencingValue[]): void {
forkJoin(tasks as Observable<BaseData<EntityId>[]>[])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((result: Array<BaseData<EntityId>>[]) => {
result.forEach((entities: BaseData<EntityId>[]) => entities.forEach((entity: BaseData<EntityId>) => this.entityNameMap.set(entity.id.id, entity.name)));
let updateTable = false;
values.forEach(({ refEntityId }) => {
if (refEntityId?.id && !this.entityNameMap.has(refEntityId.id) && refEntityId.entityType !== ArgumentEntityType.Tenant) {
updateTable = true;
const control = this.zoneGroupsFormArray.controls.find(control => control.value.refEntityId?.id === refEntityId.id);
const value = control.value;
value.refEntityId.id = NULL_UUID;
control.setValue(value, { emitEvent: false });
}
});
if (updateTable) {
this.zoneGroupsFormArray.updateValueAndValidity();
}
});
}
private getSortValue(zone: CalculatedFieldGeofencingValue, column: string): string {
switch (column) {
case 'entityType':
if (zone.refEntityId?.entityType === ArgumentEntityType.Tenant) {
return 'calculated-fields.argument-current-tenant';
} else if (zone.refDynamicSourceConfiguration.type === ArgumentEntityType.RelationQuery) {
return 'calculated-fields.argument-relation-query';
} else if (zone.refEntityId?.id) {
return entityTypeTranslations.get((zone.refEntityId)?.entityType as unknown as EntityType).type;
} else {
return 'calculated-fields.argument-current';
}
case 'key':
return zone.perimeterKeyName;
case 'reportStrategy':
return GeofencingReportStrategyTranslations.get(zone.reportStrategy);
default:
return zone.name;
}
}
private sortData(data: CalculatedFieldGeofencingValue[]): CalculatedFieldGeofencingValue[] {
return data.sort((a, b) => {
const valA = this.getSortValue(a, this.sortOrder.property) ?? '';
const valB = this.getSortValue(b, this.sortOrder.property) ?? '';
return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB);
});
}
}
class CalculatedFieldZoneDatasource extends TbTableDatasource<CalculatedFieldGeofencingValue> {
constructor() {
super();
}
}

2
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-argument-panel.component.ts

@ -86,7 +86,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
entityFilter: EntityFilter;
entityNameSubject = new BehaviorSubject<string>(null);
readonly argumentEntityTypes = Object.values(ArgumentEntityType) as ArgumentEntityType[];
readonly argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
readonly ArgumentEntityTypeTranslations = ArgumentEntityTypeTranslations;
readonly ArgumentType = ArgumentType;
readonly DataKeyType = DataKeyType;

224
ui-ngx/src/app/modules/home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component.html

@ -0,0 +1,224 @@
<!--
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.
-->
<div class="w-full max-w-xl" [formGroup]="geofencingFormGroup">
<div class="tb-form-panel no-border no-padding mb-2">
<div class="tb-form-panel-title">{{ 'calculated-fields.geofencing-zone-groups-settings' | translate }}</div>
<div class="tb-form-panel no-border no-padding">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.name' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="new-name" name="value" formControlName="name" maxlength="255" placeholder="{{ 'action.set' | translate }}"/>
@if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('duplicateName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-duplicate' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('pattern')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-pattern' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('maxlength')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-max-length' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('forbiddenName')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.name-forbidden' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<ng-container [formGroup]="refEntityIdFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="entityType">
@for (type of argumentEntityTypes; track type) {
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
@if (ArgumentEntityTypeParamsMap.has(entityType)) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div>
<tb-entity-autocomplete class="flex flex-1"
#entityAutocomplete
formControlName="id"
inlineField
[placeholder]="'action.set' | translate"
[required]="true"
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType"
(entityChanged)="entityNameSubject.next($event?.name)"/>
</div>
}
</ng-container>
<ng-container [formGroup]="refDynamicSourceFormGroup">
<div class="tb-form-panel stroked" *ngIf="entityType === ArgumentEntityType.RelationQuery">
<mat-expansion-panel class="tb-settings" expanded>
<mat-expansion-panel-header>{{ 'calculated-fields.relation-query' | translate }}*</mat-expansion-panel-header>
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="direction">
@for (direction of GeofencingDirectionList; track direction) {
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
class="flex-1"
appearance="outline"
panelWidth=""
required
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-level' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput type="number" step="1" min="0" formControlName="maxLevel" placeholder="{{ 'action.set' | translate }}"/>
@if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-required' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('min')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-min' | translate"
class="tb-error">
warning
</mat-icon>
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('max')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.relation-level-max' | translate: {max: maxRelationLevelPerCfArgument}"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
<div class="tb-form-row" [class.!hidden]="!(this.refDynamicSourceFormGroup.get('maxLevel').value > 1)">
<mat-slide-toggle class="mat-slide margin" formControlName="fetchLastLevelOnly">
{{ 'calculated-fields.fetch-last-available-level' | translate }}
</mat-slide-toggle>
</div>
</mat-expansion-panel>
</div>
</ng-container>
<ng-container>
<div class="tb-form-row">
<div class="fixed-title-width tb-required" tb-hint-tooltip-icon="{{'calculated-fields.hint.perimeter-attribute-key' | translate}}">
{{ 'calculated-fields.perimeter-attribute-key' | translate }}
</div>
<tb-entity-key-autocomplete class="flex-1" formControlName="perimeterKeyName" [dataKeyType]="DataKeyType.attribute" [entityFilter]="entityFilter"/>
</div>
<div class="tb-form-row">
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'calculated-fields.hint.report-strategy' | translate}}">{{ 'calculated-fields.report-strategy' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="reportStrategy">
@for (strategy of GeofencingReportStrategyList; track strategy) {
<mat-option [value]="strategy">{{ GeofencingReportStrategyTranslations.get(strategy) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</ng-container>
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide" formControlName="createRelationsWithMatchedZones" (click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.create-relation-with-matched-zones' | translate }}">
{{ 'calculated-fields.create-relation-with-matched-zones' | translate }}
</div>
</mat-slide-toggle>
<div class="tb-form-row" [class.!hidden]="!geofencingFormGroup.get('createRelationsWithMatchedZones').value">
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="direction">
@for (direction of GeofencingDirectionList; track direction) {
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="tb-form-row" [class.!hidden]="!geofencingFormGroup.get('createRelationsWithMatchedZones').value">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div>
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)"
additionalClass="tb-suffix-show-on-hover"
class="flex-1"
appearance="outline"
panelWidth=""
required
[errorText]="'calculated-fields.hint.relation-type-required' | translate"
formControlName="relationType">
</tb-string-autocomplete>
</div>
</div>
</div>
</div>
<div class="flex justify-end gap-2">
<button mat-button
color="primary"
type="button"
(click)="cancel()">
{{ 'action.cancel' | translate }}
</button>
<button mat-raised-button
color="primary"
type="button"
(click)="saveZone()"
[disabled]="geofencingFormGroup.invalid || !geofencingFormGroup.dirty">
{{ buttonTitle | translate }}
</button>
</div>
</div>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save