Browse Source

Merge branch 'thingsboard:master' into fix/label-card-title-translation

pull/14142/head
Maksym Tsymbarov 10 months ago
committed by GitHub
parent
commit
0062d05e0e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 8
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 8
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  3. 13
      application/src/main/java/org/thingsboard/server/actors/app/AppActor.java
  4. 9
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  5. 18
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  6. 2
      application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java
  7. 1
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  8. 5
      application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java
  9. 75
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  10. 7
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
  11. 4
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java
  12. 23
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  13. 7
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  14. 12
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
  15. 10
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  16. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
  17. 5
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  18. 61
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  19. 6
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  20. 8
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java
  21. 36
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java
  22. 45
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java
  23. 87
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java
  24. 271
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java
  25. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  26. 1
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  27. 40
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  28. 35
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  29. 6
      application/src/main/resources/thingsboard.yml
  30. 1
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  31. 255
      application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
  32. 57
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  33. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
  34. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java
  35. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
  36. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  37. 6
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java
  38. 40
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java
  39. 11
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java
  40. 39
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java
  41. 39
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java
  42. 5
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java
  43. 65
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java
  44. 19
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java
  45. 16
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java
  46. 64
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java
  47. 24
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java
  48. 64
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java
  49. 23
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java
  50. 63
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java
  51. 21
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java
  52. 17
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java
  53. 2
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
  54. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  55. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  56. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  57. 1
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java
  58. 96
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java
  59. 67
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java
  60. 29
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java
  61. 108
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java
  62. 68
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java
  63. 47
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java
  64. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java
  65. 47
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java
  66. 53
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java
  67. 31
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java
  68. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java
  69. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java
  70. 51
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java
  71. 2
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  72. 128
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java
  73. 168
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java
  74. 9
      common/proto/src/main/proto/queue.proto
  75. 2
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java
  76. 23
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  77. 2
      transport/coap/src/main/resources/tb-coap-transport.yml
  78. 2
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  79. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  80. 1
      ui-ngx/src/app/core/auth/auth.reducer.ts
  81. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  82. 70
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  83. 36
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  84. 9
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts
  85. 71
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts
  86. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts
  87. 7
      ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html
  88. 133
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html
  89. 411
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts
  90. 49
      ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts
  91. 30
      ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts
  92. 92
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html
  93. 41
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts
  94. 20
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html
  95. 18
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts
  96. 42
      ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module.ts
  97. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html
  98. 13
      ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts
  99. 1
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html
  100. 10
      ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts

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

@ -46,6 +46,12 @@ SET profile_data = jsonb_set(
WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(60)
END,
'minAllowedAggregationIntervalInSecForCF',
CASE
WHEN (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(60)
END
)
),
@ -59,6 +65,8 @@ WHERE NOT (
(profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
AND
(profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
AND
(profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF'
);
-- UPDATE TENANT PROFILE CONFIGURATION END

8
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -97,8 +97,8 @@ import org.thingsboard.server.dao.ota.OtaPackageService;
import org.thingsboard.server.dao.queue.QueueService;
import org.thingsboard.server.dao.queue.QueueStatsService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.resource.ResourceService;
import org.thingsboard.server.dao.resource.TbResourceDataCache;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.rule.RuleNodeStateService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
@ -664,6 +664,10 @@ public class ActorSystemContext {
@Getter
private long cfCalculationResultTimeout;
@Value("${actors.calculated_fields.check_interval:60}")
@Getter
private long cfCheckInterval;
@Value("${actors.alarms.reevaluation_interval:120}")
@Getter
private long alarmRulesReevaluationInterval;
@ -851,7 +855,7 @@ public class ActorSystemContext {
if (arguments != null) {
eventBuilder.arguments(JacksonUtil.toString(
arguments.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toTbelCfArg()))
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue()))
));
}
if (result != null) {

13
application/src/main/java/org/thingsboard/server/actors/app/AppActor.java

@ -32,6 +32,7 @@ import org.thingsboard.server.actors.tenant.TenantActor;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.MsgType;
@ -165,6 +166,18 @@ public class AppActor extends ContextAwareActor {
private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) {
TbActorRef target = null;
if (TenantId.SYS_TENANT_ID.equals(msg.getTenantId())) {
if (systemContext.isTenantComponentsInitEnabled()) {
if (msg.getEntityId() instanceof TenantProfileId tenantProfileId) {
tenantService.findTenantIdsByTenantProfileId(tenantProfileId).forEach(tenantId -> {
getOrCreateTenantActor(tenantId).ifPresentOrElse(tenantActor -> {
log.debug("[{}] Sending component lifecycle msg for tenant.", tenantId);
tenantActor.tellWithHighPriority(msg);
}, () -> {
log.debug("Ignoring component lifecycle msg for tenant {} because it is not managed by this service", tenantId);
});
});
}
}
if (!msg.getEntityId().getEntityType().isOneOf(EntityType.TENANT_PROFILE, EntityType.TB_RESOURCE)) {
log.warn("Message has system tenant id: {}", msg);
}

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

@ -123,9 +123,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state != null) {
state.setCtx(msg.getCtx(), actorCtx);
state.setPartition(msg.getPartition());
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) {
relatedEntitiesAggState.scheduleReevaluation();
}
states.put(cfId, state);
} else {
removeState(cfId);
@ -136,7 +133,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
log.debug("Processing CF state partition restore msg: {}", msg);
for (CalculatedFieldState state : states.values()) {
if (msg.getPartition().equals(state.getPartition())) {
state.init();
state.init(true);
}
}
}
@ -451,7 +448,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
state.init();
state.init(false);
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
@ -500,7 +497,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
} else {
if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) {
String errorMsg = ctx.isInitialized() ? state.getReadinessStatus().errorMsg() : "Calculated field state is not initialized!";
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg);
systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg);
}
callback.onSuccess();
}

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

@ -71,6 +71,7 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
@ -83,6 +84,7 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Function;
import java.util.stream.Stream;
import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
@ -187,7 +189,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} catch (Exception e) {
log.warn("[{}] Failed to trigger CFs reevaluation", tenantId, e);
}
}, systemContext.getAlarmRulesReevaluationInterval(), systemContext.getAlarmRulesReevaluationInterval(), TimeUnit.SECONDS);
}, systemContext.getCfCheckInterval(), systemContext.getCfCheckInterval(), TimeUnit.SECONDS);
}
public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException {
@ -222,6 +224,12 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
default -> msg.getCallback().onSuccess();
}
}
case TENANT_PROFILE -> {
switch (event) {
case UPDATED -> onTenantProfileUpdated(msg.getData(), msg.getCallback());
default -> msg.getCallback().onSuccess();
}
}
default -> msg.getCallback().onSuccess();
}
}
@ -247,6 +255,14 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
callback.onSuccess();
}
private void onTenantProfileUpdated(ComponentLifecycleMsg msg, TbCallback callback) {
Stream.concat(
calculatedFields.values().stream(),
entityIdCalculatedFields.values().stream().flatMap(Collection::stream)
).forEach(CalculatedFieldCtx::updateTenantProfileProperties);
callback.onSuccess();
}
private void onEntityCreated(ComponentLifecycleMsg msg, TbCallback callback) {
EntityId entityId = msg.getEntityId();
EntityId profileId = getProfileId(tenantId, entityId);

2
application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java

@ -350,7 +350,7 @@ public class TenantActor extends RuleChainManagerActor {
}
}
if (cfActor != null) {
if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER)) {
if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT_PROFILE)) {
cfActor.tellWithHighPriority(new CalculatedFieldEntityLifecycleMsg(tenantId, msg));
}
}

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

@ -165,6 +165,7 @@ public class SystemInfoController extends BaseController {
systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF());
systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument());
systemParams.setMinAllowedDeduplicationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedDeduplicationIntervalInSecForCF());
systemParams.setMinAllowedAggregationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedAggregationIntervalInSecForCF());
systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId()));
}
systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID))

5
application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java

@ -164,9 +164,14 @@ public class TenantProfileController extends BaseController {
" \"warnThreshold\": 0,\n" +
" \"maxCalculatedFieldsPerEntity\": 5,\n" +
" \"maxArgumentsPerCF\": 10,\n" +
" \"minAllowedScheduledUpdateIntervalInSecForCF\": 60,\n" +
" \"maxRelationLevelPerCfArgument\": 10,\n" +
" \"maxRelatedEntitiesToReturnPerCfArgument\": 100,\n" +
" \"maxDataPointsPerRollingArg\": 1000,\n" +
" \"maxStateSizeInKBytes\": 32,\n" +
" \"maxSingleValueArgumentSizeInKBytes\": 2" +
" \"minAllowedDeduplicationIntervalInSecForCF\": 60" +
" \"minAllowedAggregationIntervalInSecForCF\": 60" +
" }\n" +
" },\n" +
" \"default\": false\n" +

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

@ -28,7 +28,11 @@ import org.thingsboard.server.common.data.cf.CalculatedField;
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.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
@ -49,6 +53,7 @@ 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.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry;
import java.util.Collections;
import java.util.HashMap;
@ -57,6 +62,7 @@ import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
@ -66,7 +72,10 @@ import static org.thingsboard.server.common.data.cf.configuration.geofencing.Ent
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformAggMetricArgument;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformAggregationArgument;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformTsRollingArgument;
@Data
@Slf4j
@ -100,6 +109,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts);
case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts);
case RELATED_ENTITIES_AGGREGATION -> fetchRelatedEntitiesAggArguments(ctx, entityId, ts);
case ENTITY_AGGREGATION -> fetchEntityAggArguments(ctx, entityId, ts);
};
if (ctx.getCfType() == PROPAGATION) {
argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId));
@ -133,19 +143,21 @@ public abstract class AbstractCalculatedFieldProcessingService {
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);
}
}
entry -> resolveArgumentValue(entry.getKey(), entry.getValue())
));
}
protected ArgumentEntry resolveArgumentValue(String key, ListenableFuture<ArgumentEntry> future) {
try {
return future.get();
} catch (ExecutionException e) {
Throwable cause = e.getCause();
throw new RuntimeException("Failed to fetch " + key + ": " + cause.getMessage(), cause);
} catch (InterruptedException e) {
throw new RuntimeException("Failed to fetch" + key, e);
}
}
protected ListenableFuture<ArgumentEntry> fetchPropagationCalculatedFieldArgument(CalculatedFieldCtx ctx, EntityId entityId) {
ListenableFuture<List<EntityId>> propagationEntityIds = fromDynamicSource(ctx.getTenantId(), entityId, ctx.getPropagationArgument());
return Futures.transform(propagationEntityIds, ArgumentEntry::createPropagationArgument, MoreExecutors.directExecutor());
@ -186,6 +198,17 @@ public abstract class AbstractCalculatedFieldProcessingService {
));
}
protected Map<String, ListenableFuture<ArgumentEntry>> fetchEntityAggArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
if (!(ctx.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration config)) {
return Collections.emptyMap();
}
return config.getArguments().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> fetchTimeSeries(ctx.getTenantId(), entityId, entry.getValue(), config.getInterval(), ts)
));
}
protected ListenableFuture<List<EntityId>> resolveRelatedEntities(TenantId tenantId, EntityId entityId, RelationPathLevel relation) {
Predicate<EntityRelation> filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo());
ListenableFuture<List<EntityRelation>> relationsFut = relationService.findFilteredRelationsByPathQueryAsync(tenantId, new EntityRelationPathQuery(entityId, List.of(relation)), filter);
@ -285,17 +308,26 @@ public abstract class AbstractCalculatedFieldProcessingService {
};
}
protected ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) {
AggFunction function = metric.getFunction();
long intervalMs = interval.getEndTs() - interval.getStartTs();
BaseReadTsKvQuery query = new BaseReadTsKvQuery(argKey, interval.getStartTs(), interval.getEndTs(), intervalMs, 1, Aggregation.valueOf(function.name()));
ListenableFuture<ArgumentEntry> argumentEntryFut = fetchTimeSeriesInternal(tenantId, entityId, query, timeSeries -> transformAggMetricArgument(timeSeries, argKey, metric));
return resolveArgumentValue(argKey, argumentEntryFut);
}
private ListenableFuture<ArgumentEntry> fetchTimeSeries(TenantId tenantId, EntityId entityId, Argument argument, AggInterval interval, long queryEndTs) {
long intervalStartTs = interval.getCurrentIntervalStartTs();
long intervalEndTs = interval.getCurrentIntervalEndTs();
ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), intervalStartTs, queryEndTs, 0, 1, Aggregation.NONE);
return fetchTimeSeriesInternal(tenantId, entityId, query, timeSeries -> transformAggregationArgument(timeSeries, intervalStartTs, intervalEndTs));
}
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);
return fetchTimeSeriesInternal(tenantId, entityId, query, tsRolling -> transformTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow));
}
private ListenableFuture<ArgumentEntry> fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) {
@ -321,6 +353,15 @@ public abstract class AbstractCalculatedFieldProcessingService {
}, calculatedFieldCallbackExecutor));
}
private ListenableFuture<ArgumentEntry> fetchTimeSeriesInternal(TenantId tenantId, EntityId entityId, ReadTsKvQuery query, Function<List<TsKvEntry>, ArgumentEntry> transformArgument) {
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 transformArgument.apply(tsRolling);
}, calculatedFieldCallbackExecutor);
}
private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) {
long maxDataPoints = apiLimitService.getLimit(
tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg);

7
application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java

@ -17,14 +17,17 @@ package org.thingsboard.server.service.cf;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
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.id.TenantProfileId;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import java.util.stream.Stream;
public interface CalculatedFieldCache {
@ -38,7 +41,7 @@ public interface CalculatedFieldCache {
List<CalculatedFieldCtx> getCalculatedFieldCtxsByEntityId(EntityId entityId);
List<CalculatedFieldCtx> getAggCalculatedFieldCtxsByFilter(Predicate<CalculatedFieldCtx> relatedEntityFilter);
Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType);
boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter);
@ -48,6 +51,8 @@ public interface CalculatedFieldCache {
void evict(CalculatedFieldId calculatedFieldId);
void handleTenantProfileUpdate(TenantProfileId tenantProfileId);
EntityId getProfileId(TenantId tenantId, EntityId entityId);
Set<EntityId> getDynamicEntities(TenantId tenantId, EntityId entityId);

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

@ -18,6 +18,7 @@ package org.thingsboard.server.service.cf;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -25,6 +26,7 @@ import org.thingsboard.server.common.msg.queue.TbCallback;
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.aggregation.single.AggIntervalEntry;
import java.util.List;
import java.util.Map;
@ -39,6 +41,8 @@ public interface CalculatedFieldProcessingService {
Map<String, ArgumentEntry> fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map<String, Argument> arguments);
ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval);
void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback);
void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List<CalculatedFieldEntityCtxId> linkedCalculatedFields, TbCallback callback);

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

@ -24,6 +24,7 @@ import org.springframework.stereotype.Service;
import org.springframework.util.ConcurrentReferenceHashMap;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
@ -33,8 +34,10 @@ 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.id.TenantProfileId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.profile.TbAssetProfileCache;
@ -49,6 +52,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Predicate;
import java.util.stream.Stream;
@Service
@Slf4j
@ -60,6 +64,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
private final CalculatedFieldService calculatedFieldService;
private final TbAssetProfileCache assetProfileCache;
private final TbDeviceProfileCache deviceProfileCache;
private final TbTenantProfileCache tenantProfileCache;
@Lazy
private final ActorSystemContext systemContext;
private final OwnerService ownerService;
@ -146,12 +151,10 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
}
@Override
public List<CalculatedFieldCtx> getAggCalculatedFieldCtxsByFilter(Predicate<CalculatedFieldCtx> relatedEntityFilter) {
public Stream<CalculatedFieldCtx> getCalculatedFieldCtxsByType(CalculatedFieldType cfType) {
return calculatedFields.values().stream()
.filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getType()))
.map(cf -> getCalculatedFieldCtx(cf.getId()))
.filter(relatedEntityFilter)
.toList();
.filter(cf -> cfType.equals(cf.getType()))
.map(cf -> getCalculatedFieldCtx(cf.getId()));
}
@Override
@ -228,6 +231,16 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField);
}
@Override
public void handleTenantProfileUpdate(TenantProfileId tenantProfileId) {
calculatedFieldsCtx.values().stream()
.filter(ctx -> {
TenantProfile tenantProfile = tenantProfileCache.get(ctx.getTenantId());
return tenantProfile != null && tenantProfileId.equals(tenantProfile.getId());
})
.forEach(CalculatedFieldCtx::updateTenantProfileProperties);
}
@Override
public EntityId getProfileId(TenantId tenantId, EntityId entityId) {
return switch (entityId.getEntityType()) {

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

@ -24,6 +24,7 @@ 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.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -48,6 +49,7 @@ import org.thingsboard.server.queue.util.TbRuleEngineComponent;
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.aggregation.single.AggIntervalEntry;
import java.util.ArrayList;
import java.util.Collections;
@ -126,6 +128,11 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
return resolveArgumentFutures(argFutures);
}
@Override
public ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) {
return super.fetchMetricDuringInterval(tenantId, entityId, argKey, metric, interval);
}
@Override
public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) {

12
application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java

@ -27,6 +27,7 @@ import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldLink;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -188,8 +189,15 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
}
}
List<CalculatedFieldCtx> cfCtxs = calculatedFieldCache.getAggCalculatedFieldCtxsByFilter(relatedEntityFilter);
for (CalculatedFieldCtx cfCtx : cfCtxs) {
boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter);
if (hasMatchesEntityAggCfs) {
return true;
}
List<CalculatedFieldCtx> relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION)
.filter(relatedEntityFilter)
.toList();
for (CalculatedFieldCtx cfCtx : relatedEntitiesAggregationCfs) {
if (cfCtx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) {
RelationPathLevel relation = aggConfig.getRelation();
EntitySearchDirection inverseDirection = switch (relation.direction()) {

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

@ -18,11 +18,14 @@ package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.JsonNode;
import org.thingsboard.common.util.JacksonUtil;
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.aggregation.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
@ -39,7 +42,8 @@ import java.util.Map;
@JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"),
@JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING"),
@JsonSubTypes.Type(value = PropagationArgumentEntry.class, name = "PROPAGATION"),
@JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES")
@JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES"),
@JsonSubTypes.Type(value = EntityAggregationArgumentEntry.class, name = "ENTITY_AGGREGATION")
})
public interface ArgumentEntry {
@ -52,6 +56,10 @@ public interface ArgumentEntry {
boolean isEmpty();
default JsonNode jsonValue() {
return JacksonUtil.valueToTree(toTbelCfArg());
}
TbelCfArg toTbelCfArg();
boolean isForceResetPrevious();

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, GEOFENCING, PROPAGATION, RELATED_ENTITIES
SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION, RELATED_ENTITIES, ENTITY_AGGREGATION
}

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

@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.utils.CalculatedFieldUtils;
import java.io.Closeable;
@ -62,7 +63,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
}
@Override
public void init() {
public void init(boolean restored) {
}
@Override
@ -82,6 +83,8 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
validateNewEntry(key, newEntry);
if (existingEntry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {
relatedEntitiesArgumentEntry.updateEntry(newEntry);
} else if (existingEntry instanceof EntityAggregationArgumentEntry entityAggArgumentEntry) {
entityAggArgumentEntry.updateEntry(newEntry);
} else {
arguments.put(key, newEntry);
}

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

@ -45,6 +45,8 @@ import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSuppor
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
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;
@ -57,6 +59,7 @@ import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
@ -93,19 +96,20 @@ public class CalculatedFieldCtx implements Closeable {
private Output output;
private String expression;
private boolean useLatestTs;
private boolean requiresScheduledReevaluation;
private long lastReevaluationTs;
private ActorSystemContext systemContext;
private TbelInvokeService tbelInvokeService;
private RelationService relationService;
private AlarmSubscriptionService alarmService;
private CalculatedFieldProcessingService cfProcessingService;
private Map<String, CalculatedFieldScriptEngine> tbelExpressions;
private Map<String, ThreadLocal<Expression>> simpleExpressions;
private boolean initialized;
private long maxDataPointsPerRollingArg;
private long maxStateSize;
private long maxSingleValueArgumentSize;
@ -191,7 +195,6 @@ public class CalculatedFieldCtx implements Closeable {
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) {
this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L;
}
this.requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation();
if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) {
this.useLatestTs = aggConfig.isUseLatestTs();
}
@ -199,12 +202,39 @@ public class CalculatedFieldCtx implements Closeable {
this.tbelInvokeService = systemContext.getTbelInvokeService();
this.relationService = systemContext.getRelationService();
this.alarmService = systemContext.getAlarmService();
this.cfProcessingService = systemContext.getCalculatedFieldProcessingService();
this.maxDataPointsPerRollingArg = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); // fixme why tenant profile update is not handled??
this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
}
public boolean isRequiresScheduledReevaluation() {
long now = System.currentTimeMillis();
long cfCheckIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getCfCheckInterval());
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration entityAggregationConfig) {
Watermark watermark = entityAggregationConfig.getWatermark();
if (watermark != null && watermark.getDuration() > 0) {
return true;
}
long intervalEndTs = entityAggregationConfig.getInterval().getCurrentIntervalEndTs();
if (now + cfCheckIntervalMillis >= intervalEndTs) {
return true;
}
}
boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation();
if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) {
long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval());
if (requiresScheduledReevaluation) {
if (now + cfCheckIntervalMillis >= lastReevaluationTs + reevaluationIntervalMillis) {
lastReevaluationTs = now;
return true;
}
return false;
}
}
return requiresScheduledReevaluation;
}
public void init() {
switch (cfType) {
case SCRIPT -> {
@ -245,9 +275,15 @@ public class CalculatedFieldCtx implements Closeable {
});
initialized = true;
}
case ENTITY_AGGREGATION -> initialized = true;
}
}
public void updateTenantProfileProperties() {
this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024;
this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024;
}
public double evaluateSimpleExpression(Expression expression, CalculatedFieldState state) {
for (Map.Entry<String, ArgumentEntry> entry : state.getArguments().entrySet()) {
try {
@ -606,6 +642,12 @@ public class CalculatedFieldCtx implements Closeable {
&& (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) {
return true;
}
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig
&& other.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) {
boolean metricsChanged = thisConfig.getMetrics().equals(otherConfig.getMetrics());
boolean watermarkChanged = thisConfig.getWatermark().equals(otherConfig.getWatermark());
return metricsChanged || watermarkChanged;
}
return false;
}
@ -629,6 +671,9 @@ public class CalculatedFieldCtx implements Closeable {
if (hasRelatedEntitiesAggregationConfigurationChanges(other)) {
return true;
}
if (hasEntityAggregationConfigurationChanges(other)) {
return true;
}
return false;
}
@ -648,6 +693,14 @@ public class CalculatedFieldCtx implements Closeable {
return false;
}
private boolean hasEntityAggregationConfigurationChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) {
return !thisConfig.getInterval().equals(otherConfig.getInterval());
}
return false;
}
private boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalMillis != -1;
}

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

@ -28,6 +28,7 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
@ -46,7 +47,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg
@Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION"),
@Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION")
@Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION"),
@Type(value = EntityAggregationCalculatedFieldState.class, name = "ENTITY_AGGREGATION")
})
public interface CalculatedFieldState extends Closeable {
@ -61,7 +63,7 @@ public interface CalculatedFieldState extends Closeable {
void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx);
void init();
void init(boolean restored);
Map<String, ArgumentEntry> update(Map<String, ArgumentEntry> arguments, CalculatedFieldCtx ctx);

8
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java

@ -74,6 +74,14 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat
deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec());
}
@Override
public void init(boolean restored) {
super.init(restored);
if (restored) {
scheduleReevaluation();
}
}
@Override
public void close() {
super.close();

36
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java

@ -0,0 +1,36 @@
/**
* 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.aggregation.single;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class AggIntervalEntry {
private Long startTs;
private Long endTs;
public boolean belongsToInterval(long ts) {
return ts >= startTs && ts < endTs;
}
public long getIntervalDuration() {
return endTs - startTs;
}
}

45
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java

@ -0,0 +1,45 @@
/**
* 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.aggregation.single;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AggIntervalEntryStatus {
private long lastArgsRefreshTs = -1;
private long lastMetricsEvalTs = -1;
public AggIntervalEntryStatus(long lastArgsRefreshTs) {
this.lastArgsRefreshTs = lastArgsRefreshTs;
}
public boolean intervalPassed(long checkInterval) {
return lastMetricsEvalTs <= System.currentTimeMillis() - checkInterval;
}
@JsonIgnore
public boolean argsUpdated() {
return lastArgsRefreshTs > -1;
}
}

87
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java

@ -0,0 +1,87 @@
/**
* 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.aggregation.single;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.Data;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbelCfArg;
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.SingleValueArgumentEntry;
import java.util.Map;
@Data
public class EntityAggregationArgumentEntry implements ArgumentEntry {
private Map<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals;
private boolean forceResetPrevious;
public EntityAggregationArgumentEntry(Map<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals) {
this.aggIntervals = aggIntervals;
}
@Override
public ArgumentEntryType getType() {
return ArgumentEntryType.ENTITY_AGGREGATION;
}
@Override
public Object getValue() {
return aggIntervals;
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
boolean updated = false;
if (entry instanceof EntityAggregationArgumentEntry entityAggEntry) {
aggIntervals.putAll(entityAggEntry.getAggIntervals());
} else if (entry instanceof SingleValueArgumentEntry singleValueArgEntry) {
long entryTs = singleValueArgEntry.getTs();
long argUpdateTs = System.currentTimeMillis();
for (Map.Entry<AggIntervalEntry, AggIntervalEntryStatus> aggIntervalEntry : aggIntervals.entrySet()) {
if (singleValueArgEntry.isForceResetPrevious()) {
aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs);
updated = true;
continue;
}
if (aggIntervalEntry.getKey().belongsToInterval(entryTs)) {
aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs);
return true;
}
}
}
return updated;
}
@Override
public boolean isEmpty() {
return aggIntervals.isEmpty();
}
@Override
public JsonNode jsonValue() {
return JacksonUtil.valueToTree(aggIntervals);
}
@Override
public TbelCfArg toTbelCfArg() {
return null;
}
}

271
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java

@ -0,0 +1,271 @@
/**
* 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.aggregation.single;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultMetricArgumentEntry;
public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldState {
private AggInterval interval;
private long watermarkDuration;
private long checkInterval;
private Map<String, AggMetric> metrics;
private CalculatedFieldProcessingService cfProcessingService;
public EntityAggregationCalculatedFieldState(EntityId entityId) {
super(entityId);
}
@Override
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) {
super.setCtx(ctx, actorCtx);
this.cfProcessingService = ctx.getCfProcessingService();
var configuration = (EntityAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
Watermark watermark = configuration.getWatermark();
watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration());
checkInterval = TimeUnit.SECONDS.toMillis(ctx.getSystemContext().getCfCheckInterval());
interval = configuration.getInterval();
metrics = configuration.getMetrics();
}
@Override
public void init(boolean restored) {
super.init(restored);
if (restored) {
fillMissingIntervals();
}
}
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.ENTITY_AGGREGATION;
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception {
createIntervalIfNotExist();
long now = System.currentTimeMillis();
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results = new HashMap<>();
List<AggIntervalEntry> expiredIntervals = new ArrayList<>();
getIntervals().forEach((intervalEntry, argIntervalStatuses) -> {
processInterval(now, intervalEntry, argIntervalStatuses, expiredIntervals, results);
});
removeExpiredIntervals(expiredIntervals);
Output output = ctx.getOutput();
ArrayNode result = toResult(results, output.getDecimalsByDefault());
if (result.isEmpty()) {
return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY);
}
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder()
.type(output.getType())
.scope(output.getScope())
.result(result)
.build());
}
private void removeExpiredIntervals(List<AggIntervalEntry> expiredIntervals) {
expiredIntervals.forEach(expiredInterval -> {
arguments.values().stream()
.map(EntityAggregationArgumentEntry.class::cast)
.forEach(arg -> arg.getAggIntervals().remove(expiredInterval));
});
}
private void createIntervalIfNotExist() {
AggIntervalEntry currentInterval = new AggIntervalEntry(interval.getCurrentIntervalStartTs(), interval.getCurrentIntervalEndTs());
arguments.forEach((argName, argumentEntry) -> {
var entityAggEntry = (EntityAggregationArgumentEntry) argumentEntry;
entityAggEntry.getAggIntervals().computeIfAbsent(currentInterval, current -> new AggIntervalEntryStatus());
});
}
private void fillMissingIntervals() {
ZoneId zoneId = interval.getZoneId();
long currentIntervalEndTs = interval.getCurrentIntervalEndTs();
Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> intervals = getIntervals();
AggIntervalEntry lastIntervalEntry = intervals.keySet().stream().max(Comparator.comparing(AggIntervalEntry::getEndTs)).orElse(null);
if (lastIntervalEntry == null) {
return;
}
ZonedDateTime nextStart = Instant.ofEpochMilli(lastIntervalEntry.getEndTs()).atZone(zoneId);
ZonedDateTime nextEnd = interval.getNextIntervalStart(nextStart);
while (nextEnd.toInstant().toEpochMilli() <= currentIntervalEndTs) {
long nextStartTs = nextStart.toInstant().toEpochMilli();
long nextEndTs = nextEnd.toInstant().toEpochMilli();
AggIntervalEntry missing = new AggIntervalEntry(nextStartTs, nextEndTs);
arguments.forEach((argName, argumentEntry) -> {
var entityAggEntry = (EntityAggregationArgumentEntry) argumentEntry;
AggIntervalEntryStatus intervalEntryStatus = new AggIntervalEntryStatus(System.currentTimeMillis());
entityAggEntry.getAggIntervals().computeIfAbsent(missing, missingInterval -> intervalEntryStatus);
});
nextStart = nextEnd;
nextEnd = interval.getNextIntervalStart(nextStart);
}
}
private Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> getIntervals() {
Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> intervals = new HashMap<>();
arguments.forEach((argName, entry) -> {
var argEntry = (EntityAggregationArgumentEntry) entry;
argEntry.getAggIntervals().forEach((intervalEntry, status) ->
intervals.computeIfAbsent(intervalEntry, i -> new HashMap<>()).put(argName, status)
);
});
return intervals;
}
private void processInterval(long now,
AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args,
List<AggIntervalEntry> expiredIntervals,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
long startTs = intervalEntry.getStartTs();
long endTs = intervalEntry.getEndTs();
if (now - endTs > watermarkDuration) {
handleExpiredInterval(intervalEntry, args, results);
expiredIntervals.add(intervalEntry);
} else if (now - startTs >= intervalEntry.getIntervalDuration()) {
handleActiveInterval(intervalEntry, args, results);
}
}
private void handleExpiredInterval(AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
args.forEach((argName, argEntryIntervalStatus) -> {
if (argEntryIntervalStatus.getLastArgsRefreshTs() > argEntryIntervalStatus.getLastMetricsEvalTs()) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
processMetric(intervalEntry, argName, false, results);
} else if (argEntryIntervalStatus.getLastMetricsEvalTs() == -1) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
processMetric(intervalEntry, argName, true, results);
}
});
}
private void handleActiveInterval(AggIntervalEntry intervalEntry,
Map<String, AggIntervalEntryStatus> args,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
args.forEach((argName, argEntryIntervalStatus) -> {
if (argEntryIntervalStatus.intervalPassed(checkInterval)) {
if (argEntryIntervalStatus.argsUpdated()) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
argEntryIntervalStatus.setLastArgsRefreshTs(-1);
processMetric(intervalEntry, argName, false, results);
} else if (argEntryIntervalStatus.getLastMetricsEvalTs() == -1) {
argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis());
processMetric(intervalEntry, argName, true, results);
}
}
});
}
private void processMetric(AggIntervalEntry intervalEntry,
String argName,
boolean useDefault,
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results) {
String metricName = findMetricName(argName);
if (metricName != null) {
AggMetric metric = metrics.get(metricName);
String argKey = ctx.getArguments().get(argName).getRefEntityKey().getKey();
ArgumentEntry metricEntry = useDefault
? createDefaultMetricArgumentEntry(argKey, metric)
: cfProcessingService.fetchMetricDuringInterval(ctx.getTenantId(), entityId, argKey, metric, intervalEntry);
if (!metricEntry.isEmpty()) {
results.computeIfAbsent(intervalEntry, i -> new HashMap<>()).put(metricName, metricEntry);
}
}
}
private String findMetricName(String argName) {
return metrics.entrySet().stream()
.filter(e -> ((AggKeyInput) e.getValue().getInput()).getKey().equals(argName))
.map(Map.Entry::getKey)
.findFirst()
.orElse(null);
}
protected ArrayNode toResult(Map<AggIntervalEntry, Map<String, ArgumentEntry>> results, Integer precision) {
ArrayNode result = JacksonUtil.newArrayNode();
results.forEach((interval, args) -> {
ObjectNode metricsNode = JacksonUtil.newObjectNode();
for (Map.Entry<String, ArgumentEntry> entry : args.entrySet()) {
String metricName = entry.getKey();
ArgumentEntry argumentEntry = entry.getValue();
if (!argumentEntry.isEmpty()) {
Object resultValue = argumentEntry.getValue() instanceof Number number
? TbUtils.roundResult(number.doubleValue(), precision)
: argumentEntry.getValue();
metricsNode.put(metricName, JacksonUtil.toString(resultValue));
}
}
if (!metricsNode.isEmpty()) {
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", interval.getEndTs() - 1);
resultNode.set("values", metricsNode);
result.add(resultNode);
}
});
return result;
}
@Override
public boolean isReady() {
return true;
}
}

4
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java

@ -122,8 +122,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
public void init() {
super.init();
public void init(boolean restored) {
super.init(restored);
AtomicBoolean reevalNeeded = new AtomicBoolean(false);
Map<AlarmSeverity, AlarmRule> createRules = configuration.getCreateRules();
for (AlarmSeverity severity : AlarmSeverity.values()) {

1
application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java

@ -166,6 +166,7 @@ public abstract class AbstractConsumerService<N extends com.google.protobuf.Gene
tenantProfileCache.evict(tenantProfileId);
if (componentLifecycleMsg.getEvent().equals(ComponentLifecycleEvent.UPDATED)) {
apiUsageStateService.onTenantProfileUpdate(tenantProfileId);
calculatedFieldCache.handleTenantProfileUpdate(tenantProfileId);
}
} else if (EntityType.TENANT.equals(componentLifecycleMsg.getEntityId().getEntityType())) {
if (TenantId.SYS_TENANT_ID.equals(tenantId)) {

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

@ -21,6 +21,7 @@ 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.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
@ -28,6 +29,7 @@ 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.common.data.kv.TsKvEntry;
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;
@ -35,10 +37,17 @@ 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.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntryStatus;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class CalculatedFieldArgumentUtils {
@ -55,6 +64,36 @@ public class CalculatedFieldArgumentUtils {
}
}
public static ArgumentEntry transformTsRollingArgument(List<TsKvEntry> tsRolling, int limit, long argTimeWindow) {
return ArgumentEntry.createTsRollingArgument(tsRolling, limit, argTimeWindow);
}
public static ArgumentEntry transformAggMetricArgument(List<TsKvEntry> timeSeries, String argKey, AggMetric aggMetric) {
if (timeSeries == null || timeSeries.isEmpty()) {
return createDefaultMetricArgumentEntry(argKey, aggMetric);
}
return ArgumentEntry.createSingleValueArgument(timeSeries.get(0));
}
public static ArgumentEntry createDefaultMetricArgumentEntry(String argKey, AggMetric metric) {
Long defaultValue = metric.getDefaultValue();
if (defaultValue != null) {
return ArgumentEntry.createSingleValueArgument(new DoubleDataEntry(argKey, defaultValue.doubleValue()));
}
return new SingleValueArgumentEntry();
}
public static ArgumentEntry transformAggregationArgument(List<TsKvEntry> timeSeries, long startIntervalTs, long endIntervalTs) {
Map<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals = new HashMap<>();
AggIntervalEntry aggIntervalEntry = new AggIntervalEntry(startIntervalTs, endIntervalTs);
if (timeSeries == null || timeSeries.isEmpty()) {
aggIntervals.put(aggIntervalEntry, new AggIntervalEntryStatus());
} else {
aggIntervals.put(aggIntervalEntry, new AggIntervalEntryStatus(System.currentTimeMillis()));
}
return new EntityAggregationArgumentEntry(aggIntervals);
}
public static KvEntry createDefaultKvEntry(Argument argument) {
String key = argument.getRefEntityKey().getKey();
String defaultValue = argument.getDefaultValue();
@ -83,6 +122,7 @@ public class CalculatedFieldArgumentUtils {
case ALARM -> new AlarmCalculatedFieldState(entityId);
case PROPAGATION -> new PropagationCalculatedFieldState(entityId);
case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(entityId);
case ENTITY_AGGREGATION -> new EntityAggregationCalculatedFieldState(entityId);
};
}

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

@ -29,6 +29,7 @@ import org.thingsboard.server.common.util.KvProtoUtil;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.transport.TransportProtos.AlarmRuleStateProto;
import org.thingsboard.server.gen.transport.TransportProtos.AlarmStateProto;
import org.thingsboard.server.gen.transport.TransportProtos.ArgumentIntervalProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
@ -47,6 +48,10 @@ import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntryStatus;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
@ -105,6 +110,10 @@ public class CalculatedFieldUtils {
relatedEntitiesArgumentEntry.getEntityInputs()
.forEach((entityId, entry) -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) entry)));
}
case ENTITY_AGGREGATION -> {
EntityAggregationArgumentEntry entityAggregationArgumentEntry = (EntityAggregationArgumentEntry) argEntry;
entityAggregationArgumentEntry.getAggIntervals().forEach((interval, argumentStatus) -> builder.addAggregationArguments(toArgumentIntervalProto(argName, interval, argumentStatus)));
}
}
});
if (state instanceof AlarmCalculatedFieldState alarmState) {
@ -158,6 +167,16 @@ public class CalculatedFieldUtils {
return builder.build();
}
public static ArgumentIntervalProto toArgumentIntervalProto(String argName, AggIntervalEntry intervalEntry, AggIntervalEntryStatus argumentStatus) {
return ArgumentIntervalProto.newBuilder()
.setArgName(argName)
.setStartTs(intervalEntry.getStartTs())
.setEndTs(intervalEntry.getEndTs())
.setLastArgsRefreshTs(argumentStatus.getLastArgsRefreshTs())
.setLastMetricsEvalTs(argumentStatus.getLastMetricsEvalTs())
.build();
}
public static TsRollingArgumentProto toRollingArgumentProto(String argName, TsRollingArgumentEntry entry) {
TsRollingArgumentProto.Builder builder = TsRollingArgumentProto.newBuilder()
.setKey(argName)
@ -204,6 +223,7 @@ public class CalculatedFieldUtils {
case ALARM -> new AlarmCalculatedFieldState(id.entityId());
case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId());
case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(id.entityId());
case ENTITY_AGGREGATION -> new EntityAggregationCalculatedFieldState(id.entityId());
};
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) {
@ -221,6 +241,21 @@ public class CalculatedFieldUtils {
return relatedEntitiesAggState;
}
if (state instanceof EntityAggregationCalculatedFieldState entityAggregationState) {
Map<String, EntityAggregationArgumentEntry> arguments = new HashMap<>();
proto.getAggregationArgumentsList().forEach(argProto -> {
AggIntervalEntry intervalEntry = new AggIntervalEntry(argProto.getStartTs(), argProto.getEndTs());
AggIntervalEntryStatus intervalStatus = new AggIntervalEntryStatus(argProto.getLastArgsRefreshTs(), argProto.getLastMetricsEvalTs());
EntityAggregationArgumentEntry argEntry = arguments.computeIfAbsent(argProto.getArgName(), name -> new EntityAggregationArgumentEntry(new HashMap<>()));
argEntry.getAggIntervals().put(intervalEntry, intervalStatus);
});
entityAggregationState.getArguments().putAll(arguments);
return entityAggregationState;
}
proto.getSingleValueArgumentsList().forEach(argProto ->
state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto)));

6
application/src/main/resources/thingsboard.yml

@ -529,6 +529,8 @@ actors:
configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}"
# Time in seconds to receive calculation result.
calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}"
# Interval in seconds to check calculated fields for re-evaluation interval. 1 minute by default.
check_interval: "${ACTORS_CALCULATED_FIELDS_CHECK_INTERVAL_SEC:60}"
alarms:
# Interval in seconds to re-evaluate Alarm rules that have a time schedule. 2 minutes by default.
reevaluation_interval: "${ACTORS_ALARMS_REEVALUATION_INTERVAL_SEC:120}"
@ -1157,7 +1159,7 @@ transport:
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:}"
connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}"
server:
# LwM2M Server ID
id: "${LWM2M_SERVER_ID:123}"
@ -1345,7 +1347,7 @@ coap:
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:}"
connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}"
# Specify the MTU (Maximum Transmission Unit).
# Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU.
# Default = 1024

1
application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java

@ -86,6 +86,7 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await;
@Slf4j
@DaoSqlTest
@TestPropertySource(properties = {
"actors.calculated_fields.check_interval=1",
"actors.alarms.reevaluation_interval=1"
})
public class AlarmRulesTest extends AbstractControllerTest {

255
application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java

@ -0,0 +1,255 @@
/**
* 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.cf;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
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.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.aggregation.AggFunction;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.CustomInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.controller.AbstractControllerTest;
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.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL;
@DaoSqlTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
@TestPropertySource(properties = {
"actors.calculated_fields.check_interval=1"
})
public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest {
private Tenant savedTenant;
@Before
public void beforeEach() throws Exception {
loginSysAdmin();
updateDefaultTenantProfileConfig(tenantProfileConfig -> {
tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1);
tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1);
});
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
savedTenant = saveTenant(tenant);
assertThat(savedTenant).isNotNull();
User tenantAdmin = new User();
tenantAdmin.setAuthority(Authority.TENANT_ADMIN);
tenantAdmin.setTenantId(savedTenant.getId());
tenantAdmin.setEmail("tenant@thingsboard.org");
tenantAdmin.setFirstName("John");
tenantAdmin.setLastName("Doe");
createUserAndLogin(tenantAdmin, "testPassword");
}
@After
public void afterTest() throws Exception {
loginSysAdmin();
deleteTenant(savedTenant.getId());
}
@Test
public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long intervalEndTs = customInterval.getCurrentIntervalEndTs();
CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null);
long interval = customInterval.getCurrentIntervalDurationMillis();
await().alias("create CF and no telemetry during interval -> save metric with default value")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999");
});
}
@Test
public void testCreateCfWithoutWatermark_checkAggregation() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000;
long tsInInterval_1 = currentIntervalStartTs + 1000;
long tsInInterval_2 = currentIntervalStartTs + 500;
long tsInInterval_3 = currentIntervalStartTs + 200;
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis();
CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null);
await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
});
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1));
await().alias("update telemetry that belongs to previous interval -> no aggregation since watermark is not set ")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
});
}
@Test
public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception {
Device device = createDevice("Device", "1234567890111");
CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L);
long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs();
long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs();
long tsBeforeInterval = currentIntervalStartTs - 1000L;
long tsInInterval_1 = currentIntervalStartTs + 1000L;
long tsInInterval_2 = currentIntervalStartTs + 500L;
long tsInInterval_3 = currentIntervalStartTs + 200L;
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2));
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3));
long interval = customInterval.getCurrentIntervalDurationMillis();
Watermark watermark = new Watermark(10);
CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, watermark);
await().alias("create CF -> perform aggregation after interval end")
.atMost(2 * interval, TimeUnit.MILLISECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400");
});
postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1));
await().alias("update telemetry during watermark -> perform aggregation")
.atMost(2 * 10, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode result = getLatestTelemetry(device.getId(), "consumption");
assertThat(result).isNotNull();
assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600");
});
}
private CalculatedField createTotalConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) {
Map<String, Argument> arguments = new HashMap<>();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
arguments.put("en", argument);
Map<String, AggMetric> aggMetrics = new HashMap<>();
AggMetric consumption = new AggMetric();
consumption.setFunction(AggFunction.SUM);
consumption.setInput(new AggKeyInput("en"));
consumption.setDefaultValue(9999L);
aggMetrics.put("consumption", consumption);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
return createAggCf("Consumption per minute", entityId,
aggInterval,
watermark,
arguments,
aggMetrics,
output);
}
private CalculatedField createAggCf(String name,
EntityId entityId,
AggInterval aggInterval,
Watermark watermark,
Map<String, Argument> inputs,
Map<String, AggMetric> metrics,
Output output) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setName(name);
calculatedField.setEntityId(entityId);
calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION);
EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration();
configuration.setArguments(inputs);
configuration.setMetrics(metrics);
configuration.setInterval(aggInterval);
if (watermark != null) {
configuration.setWatermark(watermark);
}
configuration.setOutput(output);
calculatedField.setConfiguration(configuration);
calculatedField.setDebugSettings(DebugSettings.all());
return saveCalculatedField(calculatedField);
}
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);
}
}

57
application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java

@ -32,6 +32,11 @@ import org.thingsboard.server.common.data.cf.configuration.PropagationCalculated
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
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;
@ -45,6 +50,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
@ -166,6 +172,34 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
@Test
public void testSaveEntityAggregationCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.ENTITY_AGGREGATION);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
assertThat(savedCalculatedField).isNotNull();
assertThat(savedCalculatedField.getId()).isNotNull();
assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getEntityAggregationCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
.andExpect(status().isOk());
}
@Test
public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
@ -237,6 +271,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig());
case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig());
case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig());
case ENTITY_AGGREGATION -> calculatedField.setConfiguration(getEntityAggregationCalculatedFieldConfig());
}
calculatedField.setVersion(1L);
return calculatedField;
@ -287,6 +322,28 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
return config;
}
private CalculatedFieldConfiguration getEntityAggregationCalculatedFieldConfig() {
var config = new EntityAggregationCalculatedFieldConfiguration();
Argument energyArgument = new Argument();
energyArgument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null));
config.setArguments(Map.of("en", energyArgument));
AggMetric metric = new AggMetric();
metric.setInput(new AggKeyInput("en"));
metric.setDefaultValue(9999L);
config.setMetrics(Map.of("consumption", metric));
config.setWatermark(new Watermark(TimeUnit.DAYS.toSeconds(1)));
config.setInterval(new HourInterval("Europe/Kiev", TimeUnit.MINUTES.toSeconds(15)));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
return config;
}
private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();

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

@ -105,7 +105,7 @@ public class GeofencingCalculatedFieldStateTest {
ctx.init();
state = new GeofencingCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

2
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java

@ -105,7 +105,7 @@ public class PropagationCalculatedFieldStateTest {
state = new PropagationCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

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

@ -88,7 +88,7 @@ public class ScriptCalculatedFieldStateTest {
ctx.init();
state = new ScriptCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

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

@ -80,7 +80,7 @@ public class SimpleCalculatedFieldStateTest {
ctx.init();
state = new SimpleCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
state.init(false);
}
@Test

6
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java

@ -29,10 +29,12 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength0Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
private static final Integer serverDtlsCidLength = 0;
protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception {
testNoSecDtlsCidLength(dtlsCidLength, 0);
testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception {
testPskDtlsCidLength(dtlsCidLength, 0);
testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
}

40
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java

@ -0,0 +1,40 @@
/**
* 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.transport.lwm2m.security.cid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.dao.service.DaoSqlTest;
@TestPropertySource(properties = {
"transport.lwm2m.dtls.connection_id_length=16"
})
@DaoSqlTest
@Slf4j
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength16Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
private static final Integer serverDtlsCidLength = 16;
protected void testNoSecDtlsCidLength(Integer clientDtlsCidLength) throws Exception {
testNoSecDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer clientDtlsCidLength) throws Exception {
testPskDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength);
}
}

11
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java → application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java

@ -21,17 +21,20 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
@TestPropertySource(properties = {
"transport.lwm2m.dtls.connection_id_length=3"
"transport.lwm2m.dtls.connection_id_length=1"
})
@DaoSqlTest
@Slf4j
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength3Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength1Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
private static final Integer serverDtlsCidLength = 1;
protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception {
testNoSecDtlsCidLength(dtlsCidLength, 3);
testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception {
testPskDtlsCidLength(dtlsCidLength, 3);
testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
}

39
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.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.transport.lwm2m.security.cid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.dao.service.DaoSqlTest;
@TestPropertySource(properties = {
"transport.lwm2m.dtls.connection_id_length=2"
})
@DaoSqlTest
@Slf4j
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength2Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
private static final Integer serverDtlsCidLength = 2;
protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception {
testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception {
testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
}

39
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.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.transport.lwm2m.security.cid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.server.dao.service.DaoSqlTest;
@TestPropertySource(properties = {
"transport.lwm2m.dtls.connection_id_length=4"
})
@DaoSqlTest
@Slf4j
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength4Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
private static final Integer serverDtlsCidLength = 4;
protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception {
testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception {
testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
}

5
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java

@ -28,11 +28,12 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
@Slf4j
public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest {
private static final Integer serverDtlsCidLength = null;
protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception {
testNoSecDtlsCidLength(dtlsCidLength, null);
testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception {
testPskDtlsCidLength(dtlsCidLength, null);
testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
}
}

65
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java

@ -17,14 +17,23 @@ package org.thingsboard.server.transport.lwm2m.security.cid;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.californium.elements.config.Configuration;
import org.eclipse.californium.scandium.DTLSConnector;
import org.eclipse.californium.scandium.dtls.Connection;
import org.eclipse.californium.scandium.dtls.ConnectionId;
import org.eclipse.californium.scandium.dtls.InMemoryReadWriteLockConnectionStore;
import org.eclipse.californium.scandium.dtls.ResumptionSupportingConnectionStore;
import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpoint;
import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpointsProvider;
import org.eclipse.leshan.client.servers.LwM2mServer;
import org.eclipse.leshan.core.peer.IpPeer;
import org.junit.Assert;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
@ -39,13 +48,13 @@ public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthTest extends
protected String awaitAlias;
protected void testNoSecDtlsCidLength(Integer dtlsCidLength, Integer serverDtlsCidLength) throws Exception {
protected void testNoSecDtlsCidLength(Integer clientDtlsCidLength, Integer serverDtlsCidLength) throws Exception {
initDeviceCredentialsNoSek();
basicTestConnectionDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
basicTestConnectionDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength);
}
protected void testPskDtlsCidLength(Integer dtlsCidLength, Integer serverDtlsCidLength) throws Exception {
protected void testPskDtlsCidLength(Integer clientDtlsCidLength, Integer serverDtlsCidLength) throws Exception {
initDeviceCredentialsPsk();
basicTestConnectionDtlsCidLength(dtlsCidLength, serverDtlsCidLength);
basicTestConnectionDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength);
}
protected void basicTestConnectionDtlsCidLength(Integer clientDtlsCidLength,
@ -69,19 +78,55 @@ public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthTest extends
Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().isEmpty());
} else {
Assert.assertEquals(2L, lwM2MTestClient.getClientDtlsCid().size());
Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().keySet().contains(ON_READ_CONNECTION_ID));
Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().keySet().contains(ON_WRITE_CONNECTION_ID));
if (serverDtlsCidLength == null) {
Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().containsKey(ON_READ_CONNECTION_ID));
Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().containsKey(ON_WRITE_CONNECTION_ID));
LwM2mServer lwM2mServer = lwM2MTestClient.getLeshanClient().getRegisteredServers().entrySet().stream().findFirst().get().getValue();
CaliforniumClientEndpoint lwM2mClientEndpoint = (CaliforniumClientEndpoint) lwM2MTestClient.getLeshanClient().getEndpoint(lwM2mServer);
Connection connection = getConnection(lwM2mClientEndpoint, lwM2mServer);
ConnectionId clientCid = connection.getConnectionId();
ConnectionId readCid = connection.getEstablishedDtlsContext().getReadConnectionId();
ConnectionId serverCid = connection.getEstablishedDtlsContext().getWriteConnectionId();
if (serverDtlsCidLength == null || clientDtlsCidLength == null) {
// cid is not used
Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID));
Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID));
Assert.assertNull(readCid);
Assert.assertNull(serverCid);
} else {
Assert.assertEquals(serverDtlsCidLength, lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID));
Assert.assertEquals(clientDtlsCidLength, lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID));
if (clientDtlsCidLength == null) {
Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID));
// cid used
Assert.assertNotNull(clientCid);
Assert.assertNotNull(readCid);
if (clientDtlsCidLength > 0) {
Assert.assertEquals(clientCid, readCid);
}
Assert.assertNotNull(serverCid);
int actualServerCidLength = serverCid.getBytes().length;
int expectedServerCidLength = serverDtlsCidLength;
Assert.assertEquals(expectedServerCidLength, actualServerCidLength);
}
if (clientCid != null) {
int actualClientCidLength = clientCid.getBytes().length;
int expectedClientCidLength;
if (clientDtlsCidLength == null || clientDtlsCidLength == 0) {
expectedClientCidLength = 3;
} else {
Assert.assertEquals(Integer.valueOf(serverDtlsCidLength), lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID));
expectedClientCidLength = clientDtlsCidLength;
}
Assert.assertEquals(expectedClientCidLength, actualClientCidLength);
}
}
}
private static Connection getConnection(CaliforniumClientEndpoint lwM2mClientEndpoint, LwM2mServer lwM2mServer) throws NoSuchFieldException, IllegalAccessException {
DTLSConnector connector = (DTLSConnector) lwM2mClientEndpoint.getCoapEndpoint().getConnector();
Field field = DTLSConnector.class.getDeclaredField("connectionStore");
field.setAccessible(true);
ResumptionSupportingConnectionStore connectionStore = (InMemoryReadWriteLockConnectionStore) field.get(connector);
InetSocketAddress serverAddr = ((IpPeer) lwM2mServer.getTransportData()).getSocketAddress();
return connectionStore.get(serverAddr);
}
}

19
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java

@ -27,7 +27,7 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2
@Before
public void setUpNoSecDtlsCidLength() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE));
awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = 0";
awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = 0";
}
@Test
@ -40,8 +40,23 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2
testNoSecDtlsCidLength(0);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testNoSecDtlsCidLength(1);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testNoSecDtlsCidLength(2);
testNoSecDtlsCidLength(1);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testNoSecDtlsCidLength(4);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testNoSecDtlsCidLength(16);
}
}

16
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java

@ -27,7 +27,7 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI
@Before
public void createProfileRpc() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = 0";
awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 0";
}
@Test
@ -39,10 +39,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception {
testPskDtlsCidLength(0);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testPskDtlsCidLength(1);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testPskDtlsCidLength(2);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testPskDtlsCidLength(4);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testPskDtlsCidLength(16);
}
}

64
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java

@ -0,0 +1,64 @@
/**
* 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.transport.lwm2m.security.cid.serverDtlsCidLength_1;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength0Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength1Test;
import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength1Test {
@Before
public void createProfileRpc() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 1";
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception {
testPskDtlsCidLength(null);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception {
testPskDtlsCidLength(0);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testPskDtlsCidLength(1);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testPskDtlsCidLength(2);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testPskDtlsCidLength(4);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testPskDtlsCidLength(16);
}
}

24
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java → application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java

@ -13,21 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_3;
package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_16;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength3Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength16Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test;
import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.NO_SEC;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength3Test {
public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength16Test {
@Before
public void setUpNoSecDtlsCidLength() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE));
awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = 3";
awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = 16";
}
@Test
@ -40,8 +41,23 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2
testNoSecDtlsCidLength(0);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testNoSecDtlsCidLength(1);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testNoSecDtlsCidLength(2);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testNoSecDtlsCidLength(4);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testNoSecDtlsCidLength(16);
}
}

64
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java

@ -0,0 +1,64 @@
/**
* 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.transport.lwm2m.security.cid.serverDtlsCidLength_16;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength16Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test;
import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength16Test {
@Before
public void createProfileRpc() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 16";
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception {
testPskDtlsCidLength(null);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception {
testPskDtlsCidLength(0);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testPskDtlsCidLength(1);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testPskDtlsCidLength(2);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testPskDtlsCidLength(4);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testPskDtlsCidLength(16);
}
}

23
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java → application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java

@ -13,21 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_3;
package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_2;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength3Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength2Test;
import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength3Test {
public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength2Test {
@Before
public void createProfileRpc() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = 3";
awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 2";
}
@Test
@ -40,9 +40,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI
testPskDtlsCidLength(0);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testPskDtlsCidLength(1);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testPskDtlsCidLength(2);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testPskDtlsCidLength(4);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testPskDtlsCidLength(16);
}
}

63
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java

@ -0,0 +1,63 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_4;
import org.junit.Before;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test;
import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength4Test {
@Before
public void createProfileRpc() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 4";
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception {
testPskDtlsCidLength(null);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception {
testPskDtlsCidLength(0);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testPskDtlsCidLength(1);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testPskDtlsCidLength(2);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testPskDtlsCidLength(4);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testPskDtlsCidLength(16);
}
}

21
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java

@ -27,7 +27,7 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2
@Before
public void setUpNoSecDtlsCidLength() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE));
awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = Null";
awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = Null";
}
@Test
@ -41,7 +41,22 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testNoSecDtlsCidLength(2);
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testNoSecDtlsCidLength(1);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testNoSecDtlsCidLength(4);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_8() throws Exception {
testNoSecDtlsCidLength(8);
}
@Test
public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testNoSecDtlsCidLength(16);
}
}

17
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java

@ -27,7 +27,7 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI
@Before
public void createProfileRpc() {
transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = Null";
awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = Null";
}
@Test
@ -40,9 +40,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI
testPskDtlsCidLength(0);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception {
testPskDtlsCidLength(1);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception {
testPskDtlsCidLength(2);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception {
testPskDtlsCidLength(4);
}
@Test
public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception {
testPskDtlsCidLength(16);
}
}

2
common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java

@ -66,7 +66,7 @@ public class TbCoapDtlsSettings {
@Value("${coap.dtls.retransmission_timeout:9000}")
private int dtlsRetransmissionTimeout;
@Value("${coap.dtls.connection_id_length:}")
@Value("${coap.dtls.connection_id_length:8}")
private Integer cIdLength;
@Value("${coap.dtls.max_transmission_unit:1024}")

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

@ -41,5 +41,6 @@ public class SystemParams {
int minAllowedScheduledUpdateIntervalInSecForCF;
int maxRelationLevelPerCfArgument;
long minAllowedDeduplicationIntervalInSecForCF;
long minAllowedAggregationIntervalInSecForCF;
TrendzSettings trendzSettings;
}

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

@ -26,7 +26,8 @@ public enum CalculatedFieldType {
GEOFENCING,
ALARM,
PROPAGATION,
RELATED_ENTITIES_AGGREGATION;
RELATED_ENTITIES_AGGREGATION,
ENTITY_AGGREGATION;
public static final Set<CalculatedFieldType> all = Collections.unmodifiableSet(EnumSet.allOf(CalculatedFieldType.class));

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

@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
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.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@ -44,7 +45,8 @@ import java.util.stream.Collectors;
@Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION"),
@Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION")
@Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION"),
@Type(value = EntityAggregationCalculatedFieldConfiguration.class, name = "ENTITY_AGGREGATION")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CalculatedFieldConfiguration {

1
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java

@ -27,5 +27,6 @@ public class AggMetric {
private AggFunction function;
private String filter;
private AggInput input;
private Long defaultValue;
}

96
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java

@ -0,0 +1,96 @@
/**
* 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.aggregation.single;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
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.ArgumentType;
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.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark;
import java.util.Map;
@Data
public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
private Map<String, Argument> arguments;
@Valid
@NotEmpty
private Map<String, AggMetric> metrics;
@Valid
@NotNull
private AggInterval interval;
@Valid
private Watermark watermark;
@Valid
@NotNull
private Output output;
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.ENTITY_AGGREGATION;
}
@Override
public void validate() {
validateArguments();
validateMetrics();
validateInterval();
}
private void validateArguments() {
if (arguments.containsKey("ctx")) {
throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used.");
}
if (arguments.values().stream().anyMatch(argument -> !ArgumentType.TS_LATEST.equals(argument.getRefEntityKey().getType()))) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' support only TS_LATEST arguments.");
}
}
private void validateMetrics() {
if (metrics == null || metrics.isEmpty()) {
throw new IllegalArgumentException("Metrics map cannot be empty.");
}
for (AggMetric metric : metrics.values()) {
if (metric.getInput() instanceof AggKeyInput aggKeyInput) {
if (!arguments.containsKey(aggKeyInput.getKey())) {
throw new IllegalArgumentException(
"Metric references unknown argument: '" + aggKeyInput.getKey() + "'."
);
}
} else {
throw new IllegalArgumentException("Metric key can only refer to argument.");
}
}
}
private void validateInterval() {
if (interval == null) {
throw new IllegalArgumentException("Interval must be defined.");
}
interval.validate();
}
}

67
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java

@ -0,0 +1,67 @@
/**
* 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.aggregation.single.interval;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = HourInterval.class, name = "HOUR"),
@JsonSubTypes.Type(value = DayInterval.class, name = "DAY"),
@JsonSubTypes.Type(value = WeekInterval.class, name = "WEEK"),
@JsonSubTypes.Type(value = WeekSunSatInterval.class, name = "WEEK_SUN_SAT"),
@JsonSubTypes.Type(value = MonthInterval.class, name = "MONTH"),
@JsonSubTypes.Type(value = QuarterInterval.class, name = "QUARTER"),
@JsonSubTypes.Type(value = YearInterval.class, name = "YEAR"),
@JsonSubTypes.Type(value = CustomInterval.class, name = "CUSTOM")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface AggInterval {
@JsonIgnore
AggIntervalType getType();
@JsonIgnore
ZoneId getZoneId();
@JsonIgnore
long getCurrentIntervalDurationMillis();
@JsonIgnore
long getCurrentIntervalStartTs();
long getDateTimeIntervalStartTs(ZonedDateTime dateTime);
@JsonIgnore
long getCurrentIntervalEndTs();
long getDateTimeIntervalEndTs(ZonedDateTime dateTime);
ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart);
void validate();
}

29
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java

@ -0,0 +1,29 @@
/**
* 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.aggregation.single.interval;
public enum AggIntervalType {
HOUR,
DAY,
WEEK,
WEEK_SUN_SAT,
MONTH,
QUARTER,
YEAR,
CUSTOM
}

108
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java

@ -0,0 +1,108 @@
/**
* 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.aggregation.single.interval;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.concurrent.TimeUnit;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@AllArgsConstructor
@NoArgsConstructor
public abstract class BaseAggInterval implements AggInterval {
@NotBlank
protected String tz;
protected Long offsetSec; // delay seconds since start of interval
@Override
public ZoneId getZoneId() {
return ZoneId.of(tz);
}
protected long getOffsetSafe() {
return offsetSec != null ? offsetSec : 0L;
}
@Override
public long getCurrentIntervalDurationMillis() {
return getCurrentIntervalEndTs() - getCurrentIntervalStartTs();
}
@Override
public long getCurrentIntervalStartTs() {
ZoneId zoneId = getZoneId();
ZonedDateTime now = ZonedDateTime.now(zoneId);
return getDateTimeIntervalStartTs(now);
}
@Override
public long getDateTimeIntervalStartTs(ZonedDateTime dateTime) {
long offset = getOffsetSafe();
ZonedDateTime shiftedNow = dateTime.minusSeconds(offset);
ZonedDateTime alignedStart = getAlignedBoundary(shiftedNow, false);
ZonedDateTime actualStart = alignedStart.plusSeconds(offset);
return actualStart.toInstant().toEpochMilli();
}
@Override
public long getCurrentIntervalEndTs() {
ZoneId zoneId = getZoneId();
ZonedDateTime now = ZonedDateTime.now(zoneId);
return getDateTimeIntervalEndTs(now);
}
@Override
public long getDateTimeIntervalEndTs(ZonedDateTime dateTime) {
long offset = getOffsetSafe();
ZonedDateTime shiftedNow = dateTime.minusSeconds(offset);
ZonedDateTime alignedEnd = getAlignedBoundary(shiftedNow, true);
ZonedDateTime actualEnd = alignedEnd.plusSeconds(offset);
return actualEnd.toInstant().toEpochMilli();
}
protected abstract ZonedDateTime alignToIntervalStart(ZonedDateTime reference);
protected ZonedDateTime getAlignedBoundary(ZonedDateTime reference, boolean next) {
ZonedDateTime base = alignToIntervalStart(reference);
return next ? getNextIntervalStart(base) : base;
}
@Override
public void validate() {
try {
getZoneId();
} catch (Exception ex) {
throw new IllegalArgumentException("Invalid timezone in interval: " + ex.getMessage());
}
if (offsetSec != null) {
if (offsetSec < 0) {
throw new IllegalArgumentException("Offset cannot be negative.");
}
if (TimeUnit.SECONDS.toMillis(offsetSec) >= getCurrentIntervalDurationMillis()) {
throw new IllegalArgumentException("Offset must be greater than interval duration.");
}
}
}
}

68
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java

@ -0,0 +1,68 @@
/**
* 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.aggregation.single.interval;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.time.Duration;
import java.time.ZonedDateTime;
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class CustomInterval extends BaseAggInterval {
@NotNull
@Min(1)
private Long durationSec;
public CustomInterval(String tz, Long offsetSec, Long durationSec) {
super(tz, offsetSec);
this.durationSec = durationSec;
}
@Override
public AggIntervalType getType() {
return AggIntervalType.CUSTOM;
}
@Override
public long getCurrentIntervalDurationMillis() {
return getDurationMillis();
}
private long getDurationMillis() {
return Duration.ofSeconds(durationSec).toMillis();
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
ZonedDateTime localMidnight = reference.toLocalDate().atStartOfDay(reference.getZone());
long secondsFromMidnight = Duration.between(localMidnight, reference).getSeconds();
long alignedSecondsFromMidnight = (secondsFromMidnight / durationSec) * durationSec;
return localMidnight.plusSeconds(alignedSecondsFromMidnight);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusSeconds(durationSec);
}
}

47
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java

@ -0,0 +1,47 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
@Data
@NoArgsConstructor
public class DayInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.DAY;
}
public DayInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusDays(1);
}
}

49
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
@EqualsAndHashCode(callSuper = true)
@Data
@NoArgsConstructor
public class HourInterval extends BaseAggInterval {
public HourInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
public AggIntervalType getType() {
return AggIntervalType.HOUR;
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.truncatedTo(ChronoUnit.HOURS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusHours(1);
}
}

47
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java

@ -0,0 +1,47 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
@Data
@NoArgsConstructor
public class MonthInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.MONTH;
}
public MonthInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusMonths(1);
}
}

53
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java

@ -0,0 +1,53 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZonedDateTime;
@Data
@NoArgsConstructor
public class QuarterInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.QUARTER;
}
public QuarterInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
int month = reference.getMonthValue();
int quarterStartMonth = ((month - 1) / 3) * 3 + 1; // 1, 4, 7, 10
return ZonedDateTime.of(
LocalDate.of(reference.getYear(), quarterStartMonth, 1),
LocalTime.MIDNIGHT,
reference.getZone());
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusMonths(3);
}
}

31
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java

@ -0,0 +1,31 @@
/**
* 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.aggregation.single.interval;
import jakarta.validation.constraints.Min;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Watermark {
@Min(0)
private long duration;
}

49
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.DayOfWeek;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
@Data
@NoArgsConstructor
public class WeekInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.WEEK;
}
public WeekInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusWeeks(1);
}
}

49
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.DayOfWeek;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
@Data
@NoArgsConstructor
public class WeekSunSatInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.WEEK_SUN_SAT;
}
public WeekSunSatInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return reference.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)).truncatedTo(ChronoUnit.DAYS);
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusWeeks(1);
}
}

51
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java

@ -0,0 +1,51 @@
/**
* 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.aggregation.single.interval;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.ZonedDateTime;
@Data
@NoArgsConstructor
public class YearInterval extends BaseAggInterval {
@Override
public AggIntervalType getType() {
return AggIntervalType.YEAR;
}
public YearInterval(String tz, Long offsetSec) {
super(tz, offsetSec);
}
@Override
protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) {
return ZonedDateTime.of(
LocalDate.of(reference.getYear(), 1, 1),
LocalTime.MIDNIGHT,
reference.getZone());
}
@Override
public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) {
return currentStart.plusYears(1);
}
}

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

@ -188,6 +188,8 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura
private long maxSingleValueArgumentSizeInKBytes = 2;
@Schema(example = "60")
private long minAllowedDeduplicationIntervalInSecForCF = 60;
@Schema(example = "60")
private long minAllowedAggregationIntervalInSecForCF = 60;
@Override
public long getProfileThreshold(ApiUsageRecordKey key) {

128
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java

@ -0,0 +1,128 @@
/**
* 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.aggregation.single;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
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.Output;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class EntityAggregationCalculatedFieldConfigurationTest {
@Test
void typeShouldBeEntityAggregation() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.ENTITY_AGGREGATION);
}
@ParameterizedTest
@ValueSource(strings = {"ATTRIBUTE", "TS_ROLLING"})
void validateShouldThrowWhenNotTsLatestArgumentUsed(String argumentType) {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.valueOf(argumentType))));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Calculated field with type: '" + cfg.getType() + "' support only TS_LATEST arguments.");
}
@Test
void validateShouldThrowWhenMetricMapIsEmpty() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
cfg.setMetrics(Map.of());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Metrics map cannot be empty.");
}
@Test
void validateShouldThrowWhenMetricInputIsNotAggKeyInput() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
AggMetric metric = new AggMetric();
metric.setInput(new AggFunctionInput()); // cannot be function
cfg.setMetrics(Map.of("m", metric));
cfg.setInterval(new HourInterval("Europe/Kiev", null));
cfg.setOutput(new Output());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Metric key can only refer to argument.");
}
@Test
void validateShouldThrowWhenMetricReferencesUnknownArgument() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
AggMetric metric = new AggMetric();
metric.setInput(new AggKeyInput("unknown"));
cfg.setMetrics(Map.of("m", metric));
cfg.setInterval(new HourInterval("Europe/Kiev", null));
cfg.setOutput(new Output());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Metric references unknown argument: 'unknown'.");
}
@Test
void validateShouldThrowWhenIntervalIsNull() {
var cfg = new EntityAggregationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST)));
cfg.setMetrics(Map.of("m", validMetric()));
cfg.setInterval(null);
cfg.setOutput(new Output());
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Interval must be defined.");
}
private Argument validArgument(ArgumentType type) {
Argument a = new Argument();
a.setRefEntityKey(new ReferencedEntityKey("key", type, null));
return a;
}
private AggMetric validMetric() {
AggMetric metric = new AggMetric();
metric.setInput(new AggKeyInput("k"));
return metric;
}
}

168
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java

@ -0,0 +1,168 @@
/**
* 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.aggregation.single.interval;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.function.LongFunction;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class AggIntervalTest {
private static final String TZ = "Europe/Kiev";
@Test
void validateShouldThrowWhenInvalidTimZone() {
AggInterval interval = new HourInterval("TimeZone", null);
assertThatThrownBy(interval::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid timezone in interval: ");
}
@Test
void validateShouldThrowWhenOffsetIsNegative() {
AggInterval interval = new CustomInterval(TZ, -100L, TimeUnit.HOURS.toSeconds(2));
assertThatThrownBy(interval::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Offset cannot be negative.");
}
@Test
void validateShouldThrowWhenOffsetGreaterThanIntervalDuration() {
AggInterval interval = new CustomInterval(TZ, TimeUnit.HOURS.toSeconds(2), TimeUnit.HOURS.toSeconds(2));
assertThatThrownBy(interval::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Offset must be greater than interval duration.");
}
@ParameterizedTest
@MethodSource("intervals")
void testGetStartAndEndWithoutOffset(LongFunction<AggInterval> intervalCreator) {
AggInterval interval = intervalCreator.apply(0L);
ZonedDateTime dateTime = ZonedDateTime.of(
// 2025.11.11 00:00:00
2025, 11, 11, 0, 0, 0, 0, ZoneId.of(TZ)
);
long startTs = interval.getDateTimeIntervalStartTs(dateTime);
long endTs = interval.getDateTimeIntervalEndTs(dateTime);
assertThat(endTs).isGreaterThan(startTs);
assertThat(endTs - startTs).isEqualTo(interval.getCurrentIntervalDurationMillis());
}
@ParameterizedTest
@MethodSource("intervals")
void testApplyOffset(LongFunction<AggInterval> intervalCreator) {
long offsetSec = TimeUnit.MINUTES.toSeconds(15);
AggInterval intervalWithOffset = intervalCreator.apply(offsetSec);
AggInterval intervalNoOffset = intervalCreator.apply(0L);
ZonedDateTime dateTime = ZonedDateTime.of(
// 2025.11.11 11:20:00 - chosen so 15m offset shifts into a new interval
2025, 11, 11, 11, 20, 0, 0, ZoneId.of(TZ)
);
long startWithOffsetTs = intervalWithOffset.getDateTimeIntervalStartTs(dateTime);
long startNoOffsetTs = intervalNoOffset.getDateTimeIntervalStartTs(dateTime);
ZonedDateTime startWithOffset = Instant.ofEpochMilli(startWithOffsetTs).atZone(intervalWithOffset.getZoneId());
ZonedDateTime startNoOffset = Instant.ofEpochMilli(startNoOffsetTs).atZone(intervalNoOffset.getZoneId());
long actualOffset = Duration.between(startNoOffset, startWithOffset).toSeconds();
assertThat(actualOffset).isEqualTo(offsetSec);
}
private static Stream<Arguments> intervals() {
return Stream.of(
Arguments.of((LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset)),
Arguments.of((LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)))
);
}
@ParameterizedTest
@MethodSource("nextIntervalFromExactDate")
void testNextIntervalFromExactDate(LongFunction<AggInterval> intervalCreator, Function<ZonedDateTime, ZonedDateTime> expectedDateTimeFunction) {
AggInterval interval = intervalCreator.apply(0L);
ZonedDateTime currentStart = ZonedDateTime.of(
2025, 11, 11, 0, 0, 0, 0, ZoneId.of(TZ)
);
ZonedDateTime nextStart = interval.getNextIntervalStart(currentStart);
assertThat(nextStart).isEqualTo(expectedDateTimeFunction.apply(currentStart));
}
private static Stream<Arguments> nextIntervalFromExactDate() {
return Stream.of(
Arguments.of(
(LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusHours(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusDays(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusWeeks(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusWeeks(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusMonths(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusMonths(3)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusYears(1)
),
Arguments.of(
(LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)),
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusHours(4)
)
);
}
}

9
common/proto/src/main/proto/queue.proto

@ -916,6 +916,14 @@ message GeofencingArgumentProto {
repeated GeofencingZoneProto zones = 2;
}
message ArgumentIntervalProto {
string argName = 1;
int64 startTs = 2;
int64 endTs = 3;
int64 lastArgsRefreshTs = 4;
int64 lastMetricsEvalTs = 5;
}
message CalculatedFieldStateProto {
CalculatedFieldEntityCtxIdProto id = 1;
string type = 2;
@ -925,6 +933,7 @@ message CalculatedFieldStateProto {
AlarmStateProto alarmState = 6;
int64 lastArgsUpdateTs = 7;
int64 lastMetricsEvalTs = 8;
repeated ArgumentIntervalProto aggregationArguments = 9;
}
//Used to report session state to tb-Service and persist this state in the cache on the tb-Service level.

2
common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java

@ -43,7 +43,7 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig {
private int dtlsRetransmissionTimeout;
@Getter
@Value("${transport.lwm2m.dtls.connection_id_length:}")
@Value("${transport.lwm2m.dtls.connection_id_length:8}")
private Integer dtlsCidLength;
@Getter

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

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalcula
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.cf.CalculatedFieldDao;
@ -31,6 +32,7 @@ import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Component
@ -49,6 +51,7 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
validateSchedulingConfiguration(tenantId, calculatedField);
validateRelationQuerySourceArguments(tenantId, calculatedField);
validateAggregationConfiguration(tenantId, calculatedField);
validateEntityAggregationConfiguration(tenantId, calculatedField);
}
@Override
@ -120,10 +123,24 @@ public class CalculatedFieldDataValidator extends DataValidator<CalculatedField>
if (!(calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfiguration)) {
return;
}
long minAllowedDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF);
if (aggConfiguration.getDeduplicationIntervalInSec() < minAllowedDeduplicationInterval) {
long minDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF);
if (aggConfiguration.getDeduplicationIntervalInSec() < minDeduplicationInterval) {
throw new IllegalArgumentException("Deduplication interval is less than configured " +
"minimum allowed interval in tenant profile: " + minAllowedDeduplicationInterval);
"minimum allowed interval in tenant profile: " + minDeduplicationInterval);
}
}
private void validateEntityAggregationConfiguration(TenantId tenantId, CalculatedField calculatedField) {
if (!(calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration aggConfiguration)) {
return;
}
long minAggregationIntervalInSec = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedAggregationIntervalInSecForCF);
if (minAggregationIntervalInSec <= 0) {
return;
}
if (aggConfiguration.getInterval().getCurrentIntervalDurationMillis() < TimeUnit.SECONDS.toMillis(minAggregationIntervalInSec)) {
throw new IllegalArgumentException("Aggregation interval duration is less than configured " +
"minimum allowed aggregation interval in tenant profile: " + minAggregationIntervalInSec + " sec.");
}
}

2
transport/coap/src/main/resources/tb-coap-transport.yml

@ -193,7 +193,7 @@ coap:
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:}"
connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}"
# Specify the MTU (Maximum Transmission Unit).
# Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU.
# Default = 1024

2
transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml

@ -173,7 +173,7 @@ transport:
# - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic).
# - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used
# - A value that are > 4: MultiNodeConnectionIdGenerator is used
connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:}"
connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}"
server:
# LwM2M Server ID
id: "${LWM2M_SERVER_ID:123}"

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

@ -32,6 +32,7 @@ export interface SysParamsState {
maxDataPointsPerRollingArg: number;
maxArgumentsPerCF: number;
minAllowedDeduplicationIntervalInSecForCF: number;
minAllowedAggregationIntervalInSecForCF: number;
minAllowedScheduledUpdateIntervalInSecForCF: number;
maxRelationLevelPerCfArgument: number;
ruleChainDebugPerTenantLimitsConfiguration?: string;

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

@ -34,6 +34,7 @@ const emptyUserAuthState: AuthPayload = {
maxResourceSize: 0,
maxArgumentsPerCF: 0,
minAllowedDeduplicationIntervalInSecForCF: 0,
minAllowedAggregationIntervalInSecForCF: 0,
minAllowedScheduledUpdateIntervalInSecForCF: 0,
maxRelationLevelPerCfArgument: 0,
maxDataPointsPerRollingArg: 0,

4
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts

@ -41,6 +41,9 @@ import {
import {
RelatedEntitiesAggregationComponentModule
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module';
import {
EntityAggregationComponentModule
} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module';
@NgModule({
declarations: [
@ -56,6 +59,7 @@ import {
SimpleConfigurationModule,
PropagationConfigurationModule,
RelatedEntitiesAggregationComponentModule,
EntityAggregationComponentModule,
],
exports: [
CalculatedFieldDialogComponent,

70
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html

@ -74,25 +74,27 @@
}
</ng-container>
<ng-container [formGroup]="refEntityKeyFormGroup">
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (type of argumentTypes; track type) {
<mat-option [value]="type">{{ ArgumentTypeTranslations.get(type) | translate }}</mat-option>
@if (!hiddenEntityKeyTypes) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-type' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (type of argumentTypes; track type) {
<mat-option [value]="type">{{ ArgumentTypeTranslations.get(type) | translate }}</mat-option>
}
</mat-select>
@if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-select>
@if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.hint.argument-type-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
</mat-form-field>
</div>
}
@if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) {
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) {
<div class="tb-form-row">
@ -149,21 +151,23 @@
}"></ng-container>
}
@if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) {
<div class="tb-form-row">
<div class="fixed-title-width" [class.tb-required]="defaultValueRequired">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1 overflow-hidden" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.default-value-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
@if (!hiddenDefaultValue) {
<div class="tb-form-row">
<div class="fixed-title-width" [class.tb-required]="defaultValueRequired">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1 overflow-hidden" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
@if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) {
<mat-icon matSuffix
matTooltipPosition="above"
matTooltipClass="tb-error-tooltip"
[matTooltip]="'calculated-fields.default-value-required' | translate"
class="tb-error">
warning
</mat-icon>
}
</mat-form-field>
</div>
}
} @else {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.time-window' | translate }}</div>

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

@ -25,7 +25,7 @@ import {
ViewChild
} from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants';
import {
ArgumentEntityType,
@ -34,7 +34,10 @@ import {
ArgumentType,
ArgumentTypeTranslations,
CalculatedFieldArgumentValue,
getCalculatedFieldCurrentEntityFilter
FORBIDDEN_NAMES,
forbiddenNamesValidator,
getCalculatedFieldCurrentEntityFilter,
uniqueNameValidator
} from '@shared/models/calculated-field.models';
import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
@ -70,9 +73,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
@Input() usedArgumentNames: string[];
@Input() isOutputKey = false;
@Input() hiddenEntityTypes = false;
@Input() hiddenEntityKeyTypes = false;
@Input() hiddenDefaultValue = false;
@Input() defaultValueRequired = false;
@Input() hint: string;
@Input() predefinedEntityFilter: EntityFilter;
@Input() forbiddenNames = FORBIDDEN_NAMES;
@Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[];
@ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent;
@ -85,7 +91,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10);
argumentFormGroup = this.fb.group({
argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
argumentName: ['', [Validators.required, Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: [null],
refEntityKey: this.fb.group({
type: [ArgumentType.LatestTelemetry, [Validators.required]],
@ -140,6 +146,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
ngOnInit(): void {
this.updatedFormValidators();
this.updatedArgumentType();
this.argumentFormGroup.patchValue(this.argument, {emitEvent: false});
this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId);
@ -186,6 +193,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
this.popover.hide();
}
private updatedFormValidators(): void {
this.argumentFormGroup.get('argumentName').addValidators(
[uniqueNameValidator(this.usedArgumentNames), forbiddenNamesValidator(this.forbiddenNames)]);
this.argumentFormGroup.get('argumentName').updateValueAndValidity({emitEvent: false});
}
private updatedArgumentType(): void {
let argumentType = ArgumentEntityType.Current;
if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) {
@ -264,15 +277,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
});
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedArgumentNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private observeEntityKeyChanges(): void {
this.argumentFormGroup.get('refEntityKey').get('type').valueChanges
.pipe(takeUntilDestroyed())
@ -306,14 +310,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
}
}
private forbiddenArgumentNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx'];
return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void {
const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type);
this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']({emitEvent});

9
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts

@ -29,6 +29,9 @@ import {
import {
RelatedAggregationArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component';
import {
EntityAggregationArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component';
@NgModule({
imports: [
@ -39,12 +42,14 @@ import {
CalculatedFieldArgumentPanelComponent,
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
RelatedAggregationArgumentsTableComponent,
EntityAggregationArgumentsTableComponent,
],
exports: [
CalculatedFieldArgumentsTableComponent,
PropagateArgumentsTableComponent,
RelatedAggregationArgumentsTableComponent
RelatedAggregationArgumentsTableComponent,
EntityAggregationArgumentsTableComponent,
]
})
export class CalculatedFieldArgumentsTableModule {}

71
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts

@ -0,0 +1,71 @@
///
/// 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 { ChangeDetectorRef, Component, DestroyRef, forwardRef, Renderer2, ViewContainerRef, } from '@angular/core';
import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms';
import { TbPopoverService } from '@shared/components/popover.service';
import { EntityService } from '@core/http/entity.service';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component';
import { ArgumentEntityType } from '@shared/models/calculated-field.models';
@Component({
selector: 'tb-entity-aggregation-arguments-table',
templateUrl: './calculated-field-arguments-table.component.html',
styleUrls: [`calculated-field-arguments-table.component.scss`],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent),
multi: true
}
],
})
export class EntityAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent {
constructor(
protected fb: FormBuilder,
protected popoverService: TbPopoverService,
protected viewContainerRef: ViewContainerRef,
protected cd: ChangeDetectorRef,
protected renderer: Renderer2,
protected entityService: EntityService,
protected destroyRef: DestroyRef,
protected store: Store<AppState>
) {
super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store);
this.argumentNameColumn = 'calculated-fields.argument-name';
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
hiddenEntityTypes: true,
argumentEntityTypes: [ArgumentEntityType.Current],
hint: 'calculated-fields.entity-aggregation.argument-setting-hint',
hiddenDefaultValue: true,
hiddenEntityKeyTypes: true,
};
this.isScript = false;
}
}

10
ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts

@ -31,7 +31,12 @@ import { AppState } from '@core/core.state';
import {
CalculatedFieldArgumentsTableComponent
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component';
import { ArgumentEntityType, ArgumentType, CalculatedFieldArgumentValue } from '@shared/models/calculated-field.models';
import {
ArgumentEntityType,
ArgumentType,
CalculatedFieldArgumentValue,
FORBIDDEN_NAMES
} from '@shared/models/calculated-field.models';
import { isDefined, isUndefinedOrNull } from '@core/utils';
import { NULL_UUID } from '@shared/models/id/has-uuid';
@ -88,7 +93,8 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
argumentEntityTypes: [ArgumentEntityType.Current],
isOutputKey: true
isOutputKey: true,
forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'],
};
}
}

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

@ -90,6 +90,13 @@
[tenantId]="data.tenantId"
></tb-related-entities-aggregation-component>
}
@case (CalculatedFieldType.ENTITY_AGGREGATION) {
<tb-entity-aggregation-component formControlName="configuration"
[entityId]="data.entityId"
[entityName]="data.entityName"
[tenantId]="data.tenantId"
></tb-entity-aggregation-component>
}
@default {
<tb-simple-configuration formControlName="configuration"
[entityId]="data.entityId"

133
ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html

@ -0,0 +1,133 @@
<!--
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 [formGroup]="entityAggregationConfiguration" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel">
<div class="tb-form-panel-title">
{{ 'calculated-fields.arguments' | translate }}
</div>
<div class="tb-form-hint tb-primary-fill hint-container">
{{ 'calculated-fields.entity-aggregation.argument-hint' | translate }}
</div>
<tb-entity-aggregation-arguments-table formControlName="arguments"
[entityId]="entityId"
[tenantId]="tenantId"
[entityName]="entityName"/>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}">
{{ 'calculated-fields.metrics.metrics' | translate }}
</div>
<tb-calculated-field-metrics-table formControlName="metrics"
simpleMode
[arguments]="arguments$ | async"
></tb-calculated-field-metrics-table>
</div>
<div class="tb-form-panel">
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.aggregation-interval-hint' | translate }}">
{{ 'calculated-fields.entity-aggregation.aggregation-interval' | translate }}
</div>
<ng-container formGroupName="interval">
<div class="flex items-start gap-3 xs:flex-col xs:items-stretch">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>calculated-fields.aggregate-interval-type</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of AggIntervalTypes" [value]="type">
{{ AggIntervalTypeTranslations.get(type) | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<tb-timezone-select
class="flex-1"
appearance="outline"
subscriptSizing="dynamic"
required
userTimezoneByDefault
formControlName="tz">
</tb-timezone-select>
</div>
@if (entityAggregationConfiguration.get('interval.type').value === AggIntervalType.CUSTOM) {
<tb-time-unit-input required
[minTime]="minAllowedAggregationIntervalInSecForCF"
[stepMultipleOf]="DayInSec"
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
containerClass="flex gap-3"
labelText="{{ 'calculated-fields.aggregate-interval-value' | translate }}"
minErrorText="{{ 'calculated-fields.aggregate-interval-value-min' | translate : {sec: minAllowedAggregationIntervalInSecForCF} }}"
requiredText="{{ 'calculated-fields.aggregate-interval-value-required' | translate }}"
stepMultipleOfErrorText="{{ 'calculated-fields.aggregate-interval-value-step-multiple-of' | translate }}"
formControlName="durationSec">
</tb-time-unit-input>
}
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide flex" formControlName="allowOffsetSec">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.apply-offset-hint' | translate }}">
{{ 'calculated-fields.entity-aggregation.apply-offset' | translate }}
</div>
</mat-slide-toggle>
@if (entityAggregationConfiguration.get('interval.allowOffsetSec').value) {
<tb-time-unit-input required
[minTime]="0"
[maxTime]="maxOffsetTime"
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
containerClass="flex gap-3"
labelText="{{ 'calculated-fields.entity-aggregation.offset-value' | translate }}"
minErrorText="{{ 'calculated-fields.entity-aggregation.offset-value-min' | translate }}"
maxErrorText="{{ 'calculated-fields.entity-aggregation.offset-value-max' | translate }}"
requiredText="{{ 'calculated-fields.entity-aggregation.offset-value-required' | translate }}"
formControlName="offsetSec">
</tb-time-unit-input>
<div class="tb-form-hint tb-primary-fill hint-container">
{{ hint }}
</div>
}
</div>
</ng-container>
<div class="tb-form-panel stroked">
<mat-slide-toggle class="mat-slide flex" formControlName="allowWatermark">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.wait-delay-hint' | translate }}">
{{ 'calculated-fields.entity-aggregation.wait-delay' | translate }}
</div>
</mat-slide-toggle>
@if (entityAggregationConfiguration.get('allowWatermark').value) {
<ng-container formGroupName="watermark">
<tb-time-unit-input required
[minTime]="60"
sameWidthInputs
appearance="outline"
containerClass="flex gap-3"
labelText="{{ 'calculated-fields.entity-aggregation.duration' | translate }}"
minErrorText="{{ 'calculated-fields.entity-aggregation.duration-min' | translate }}"
hintText="{{ 'calculated-fields.entity-aggregation.duration-hint' | translate }}"
requiredText="{{ 'calculated-fields.entity-aggregation.duration-required' | translate }}"
formControlName="duration">
</tb-time-unit-input>
</ng-container>
}
</div>
</div>
<tb-calculate-field-output formControlName="output"
containerInputClass="grid items-start grid-cols-2 gap-3 xs:grid-cols-1"
simpleMode
hiddenName
disableType
[entityId]="entityId"></tb-calculate-field-output>
</div>

411
ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts

@ -0,0 +1,411 @@
///
/// 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 { Component, forwardRef, Input } from '@angular/core';
import {
ControlValueAccessor,
FormBuilder,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
ValidationErrors,
Validator,
Validators
} from '@angular/forms';
import { EntityId } from '@shared/models/id/entity-id';
import {
AggInterval,
AggIntervalType,
AggIntervalTypeTranslations,
CalculatedFieldEntityAggregationConfiguration,
CalculatedFieldOutput,
CalculatedFieldType,
notEmptyObjectValidator,
OutputType
} from '@shared/models/calculated-field.models';
import { filter, map } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, YEAR } from '@shared/models/time/time.models';
import { deepClone, isDefinedAndNotNull } from '@core/utils';
import { getCurrentAuthState } from '@core/auth/auth.selectors';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { merge } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import _moment from 'moment';
interface CalculatedFieldEntityAggregationConfigurationValue extends CalculatedFieldEntityAggregationConfiguration {
interval: AggInterval & {allowOffsetSec?: boolean};
allowWatermark: boolean;
}
enum TimeCategory {
SECONDS = 'SECONDS',
MINUTES = 'MINUTES',
HOURS = 'HOURS',
DAYS = 'DAYS'
}
@Component({
selector: 'tb-entity-aggregation-component',
templateUrl: './entity-aggregation-component.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => EntityAggregationComponentComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => EntityAggregationComponentComponent),
multi: true
}
],
})
export class EntityAggregationComponentComponent implements ControlValueAccessor, Validator {
@Input({required: true})
entityId: EntityId;
@Input({required: true})
tenantId: string;
@Input({required: true})
entityName: string;
readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF;
readonly DayInSec = DAY / SECOND;
entityAggregationConfiguration = this.fb.group({
arguments: this.fb.control({}, notEmptyObjectValidator()),
metrics: this.fb.control({}, notEmptyObjectValidator()),
interval: this.fb.group({
type: [AggIntervalType.HOUR],
tz: ['', Validators.required],
durationSec: [this.minAllowedAggregationIntervalInSecForCF, Validators.required],
allowOffsetSec: [false],
offsetSec: [this.minAllowedAggregationIntervalInSecForCF > 60 ? MINUTE / SECOND : 1, Validators.required],
}),
allowWatermark: [false],
watermark: this.fb.group({
duration: [HOUR/SECOND, Validators.required],
}),
output: this.fb.control<CalculatedFieldOutput>({
type: OutputType.Timeseries,
}),
});
arguments$ = this.entityAggregationConfiguration.get('arguments').valueChanges.pipe(
map(argumentsObj => Object.keys(argumentsObj))
);
AggIntervalType = AggIntervalType;
AggIntervalTypes = Object.values(AggIntervalType) as AggIntervalType[];
AggIntervalTypeTranslations = AggIntervalTypeTranslations;
hint: string;
private propagateChange: (config: CalculatedFieldEntityAggregationConfiguration) => void = () => { };
constructor(private fb: FormBuilder,
private store: Store<AppState>,
private translate: TranslateService,) {
this.entityAggregationConfiguration.get('interval.type').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((type: AggIntervalType) => {
this.checkAggIntervalType(type);
});
this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((allow: boolean) => {
this.checkIntervalDuration(allow);
});
this.entityAggregationConfiguration.get('allowWatermark').valueChanges.pipe(
takeUntilDestroyed()
).subscribe((allow: boolean) => {
this.checkWatermark(allow);
});
merge(
this.entityAggregationConfiguration.get('interval.type').valueChanges,
this.entityAggregationConfiguration.get('interval.durationSec').valueChanges,
this.entityAggregationConfiguration.get('interval.offsetSec').valueChanges,
this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges,
).pipe(
filter(() => this.entityAggregationConfiguration.get('interval.allowOffsetSec').value),
takeUntilDestroyed()
).subscribe(() => {
this.updatedOffsetHint();
});
this.entityAggregationConfiguration.valueChanges.pipe(
takeUntilDestroyed()
).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => {
this.updatedModel(deepClone(value));
});
}
validate(): ValidationErrors | null {
return this.entityAggregationConfiguration.valid || this.entityAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false};
}
writeValue(value: CalculatedFieldEntityAggregationConfiguration): void {
const data: CalculatedFieldEntityAggregationConfigurationValue = {
...value,
allowWatermark: isDefinedAndNotNull(value.watermark),
interval: {...value.interval, allowOffsetSec: isDefinedAndNotNull(value?.interval?.offsetSec)}
}
this.entityAggregationConfiguration.patchValue(data, {emitEvent: false});
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
this.updatedOffsetHint();
setTimeout(() => {
this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true});
});
}
registerOnChange(fn: (config: CalculatedFieldEntityAggregationConfiguration) => void): void {
this.propagateChange = fn;
}
registerOnTouched(_: any): void { }
setDisabledState(isDisabled: boolean): void {
if (isDisabled) {
this.entityAggregationConfiguration.disable({emitEvent: false});
} else {
this.entityAggregationConfiguration.enable({emitEvent: false});
this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value);
this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value);
this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value);
}
}
get maxOffsetTime(): number {
switch (this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType) {
case AggIntervalType.HOUR:
return HOUR / SECOND - 1;
case AggIntervalType.DAY:
return DAY / SECOND - 1;
case AggIntervalType.WEEK:
case AggIntervalType.WEEK_SUN_SAT:
return 7 * DAY / SECOND - 1;
case AggIntervalType.MONTH:
return AVG_MONTH / SECOND;
case AggIntervalType.QUARTER:
return AVG_QUARTER / SECOND - 1;
case AggIntervalType.YEAR:
return YEAR / SECOND - 1;
case AggIntervalType.CUSTOM:
return this.entityAggregationConfiguration.get('interval.durationSec').value - 1;
}
}
private updatedModel(value: CalculatedFieldEntityAggregationConfigurationValue): void {
value.type = CalculatedFieldType.ENTITY_AGGREGATION;
if (!value.interval.allowOffsetSec) {
delete value.interval.offsetSec;
}
delete value.interval.allowOffsetSec;
if (!value.allowWatermark) {
delete value.watermark;
}
delete value.allowWatermark;
this.propagateChange(value);
}
private checkAggIntervalType(type: AggIntervalType) {
if (type === AggIntervalType.CUSTOM) {
this.entityAggregationConfiguration.get('interval.durationSec').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('interval.durationSec').disable({emitEvent: false});
}
}
private checkIntervalDuration(allow: boolean) {
if (allow) {
this.entityAggregationConfiguration.get('interval.offsetSec').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('interval.offsetSec').disable({emitEvent: false});
this.hint = '';
}
}
private checkWatermark(allow: boolean) {
if (allow) {
this.entityAggregationConfiguration.get('watermark').enable({emitEvent: false});
} else {
this.entityAggregationConfiguration.get('watermark').disable({emitEvent: false});
}
}
private updatedOffsetHint(): void {
const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value;
const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType;
const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value;
const offsetCategory = this.getTimeCategory(offset);
const now = _moment.utc();
let interval: string = '';
if (intervalType === AggIntervalType.CUSTOM) {
const durationSecCategory = this.getTimeCategory(durationSec);
const formatString = this.getCustomFormatString(offsetCategory, durationSecCategory);
const intervals: string[] = [];
let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND;
now.startOf('year').add(offset, 'seconds');
let repeat = 2;
if (allInterval) {
repeat = Math.floor(DAY/SECOND/durationSec);
if (repeat > 4) {
repeat = 2;
allInterval = false;
}
}
for (let i = 0; i < repeat; i++) {
const s1 = now.clone().add(i * durationSec, 'seconds').format(formatString);
const s2 = now.clone().add((i + 1) * durationSec, 'seconds').format(formatString);
intervals.push(`${s1} - ${s2}`);
}
interval = intervals.join('; ');
if (allInterval) {
this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset', {interval});
} else {
interval += '…'
this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', {interval});
}
} else {
interval = this.buildStandardIntervalString(now, intervalType, offset, offsetCategory);
this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', { interval });
}
}
private getTimeCategory(seconds: number): TimeCategory {
if (seconds % (DAY / SECOND) === 0) {
return TimeCategory.DAYS;
}
if (seconds % (HOUR / SECOND) === 0) {
return TimeCategory.HOURS;
}
if (seconds % (MINUTE / SECOND) === 0) {
return TimeCategory.MINUTES;
}
return TimeCategory.SECONDS;
}
private getCustomFormatString(offsetCat: TimeCategory, durationCat: TimeCategory): string {
if (durationCat === TimeCategory.DAYS) {
if (offsetCat === TimeCategory.SECONDS) {
return '[Day] D, HH:mm:ss';
}
if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) {
return '[Day] D, HH:mm';
}
return '[Day] D';
} else {
if (offsetCat === TimeCategory.SECONDS) {
return 'HH:mm:ss';
}
return 'HH:mm';
}
}
private formatAdditiveInterval(now: _moment.Moment, addUnit: 'hour' | 'day' | 'month' | 'quarter', offsetCat: TimeCategory,
formats: { [key in TimeCategory]?: { s1: string, s2: string, s3: string } }): string {
const formatTs = formats[offsetCat] || formats[TimeCategory.SECONDS];
if (!formatTs) {
return '';
}
const s1 = now.format(formatTs.s1);
const s2 = now.clone().add(1, addUnit).format(formatTs.s2);
const s3 = now.clone().add(2, addUnit).format(formatTs.s3);
return `${s1} - ${s2}; ${s2} - ${s3}`;
}
private formatNextInterval(now: _moment.Moment, offsetCat: TimeCategory, secFmt: string, minHourFmt: string, dayFmt: string): string {
let s1: string;
if (offsetCat === TimeCategory.SECONDS) {
s1 = now.format(secFmt);
} else if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) {
s1 = now.format(minHourFmt);
} else {
s1 = now.format(dayFmt);
}
const s2 = `Next ${s1}`;
const s3 = `Following ${s1}`;
return `${s1} - ${s2}; ${s2} - ${s3}`;
}
private buildStandardIntervalString(now: _moment.Moment, type: AggIntervalType, offset: number, offsetCat: TimeCategory): string {
switch (type) {
case AggIntervalType.HOUR:
now.startOf('day').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'hour', offsetCat, {
[TimeCategory.SECONDS]: { s1: 'HH:mm:ss', s2: 'HH:mm:ss', s3: 'HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: 'HH:mm:ss', s2: 'HH:mm', s3: 'HH:mm' }
});
case AggIntervalType.DAY:
now.startOf('month').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'day', offsetCat, {
[TimeCategory.SECONDS]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm:ss', s3: '[Day] D, HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' },
[TimeCategory.HOURS]: { s1: 'HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' } // Note: Original logic, s1 format is different
});
case AggIntervalType.WEEK:
now.isoWeekday(1).startOf('isoWeek').add(offset, 'seconds');
return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd');
case AggIntervalType.WEEK_SUN_SAT:
now.startOf('week').add(offset, 'seconds');
return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd');
case AggIntervalType.MONTH:
now.startOf('year').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'month', offsetCat, {
[TimeCategory.SECONDS]: { s1: 'Do [of month], HH:mm:ss', s2: '[Next] Do, HH:mm:ss', s3: '[Following] Do, HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' },
[TimeCategory.HOURS]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' },
[TimeCategory.DAYS]: { s1: 'Do [of month]', s2: '[Next] Do', s3: '[Following] Do' }
});
case AggIntervalType.QUARTER:
now.startOf('year').add(offset, 'seconds');
return this.formatAdditiveInterval(now, 'quarter', offsetCat, {
[TimeCategory.SECONDS]: { s1: 'MMM Do, HH:mm:ss', s2: 'MMM Do, HH:mm:ss', s3: 'MMM Do, HH:mm:ss' },
[TimeCategory.MINUTES]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' },
[TimeCategory.HOURS]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' },
[TimeCategory.DAYS]: { s1: 'MMM Do', s2: 'MMM Do', s3: 'MMM Do' }
});
case AggIntervalType.YEAR:
now.startOf('year').add(offset, 'seconds');
return this.formatNextInterval(now, offsetCat, 'MMM Do, HH:mm:ss', 'MMM Do, HH:mm', 'MMM Do');
default:
return '';
}
}
}

49
ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts

@ -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.
///
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import {
CalculatedFieldOutputModule
} from '@home/components/calculated-fields/components/output/calculated-field-output.module';
import {
CalculatedFieldArgumentsTableModule
} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module';
import {
EntityAggregationComponentComponent
} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component';
import {
CalculatedFieldMetricsTableModule
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module';
@NgModule({
imports: [
CommonModule,
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
CalculatedFieldMetricsTableModule,
],
declarations: [
EntityAggregationComponentComponent,
],
exports: [
EntityAggregationComponentComponent,
]
})
export class EntityAggregationComponentModule {
}

30
ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts

@ -32,11 +32,14 @@ import {
ArgumentEntityTypeTranslations,
CalculatedFieldGeofencing,
CalculatedFieldGeofencingValue,
FORBIDDEN_NAMES,
forbiddenNamesValidator,
GeofencingDirectionLevelTranslations,
GeofencingDirectionTranslations,
GeofencingReportStrategy,
GeofencingReportStrategyTranslations,
getCalculatedFieldCurrentEntityFilter
getCalculatedFieldCurrentEntityFilter,
uniqueNameValidator
} from '@shared/models/calculated-field.models';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';
import { EntityType } from '@shared/models/entity-type.models';
@ -76,7 +79,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
readonly maxRelationLevelPerCfArgument = getCurrentAuthState(this.store).maxRelationLevelPerCfArgument;
geofencingFormGroup = this.fb.group({
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
refEntityId: this.fb.group({
entityType: [ArgumentEntityType.Current],
id: ['']
@ -133,6 +136,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
}
ngOnInit(): void {
this.updatedFormValidators();
this.geofencingFormGroup.patchValue(this.zone, {emitEvent: false});
if (this.zone.refDynamicSourceConfiguration?.type) {
this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false});
@ -159,6 +163,11 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search))));
}
private updatedFormValidators(): void {
this.geofencingFormGroup.get('name').addValidators(uniqueNameValidator(this.usedNames));
this.geofencingFormGroup.get('name').updateValueAndValidity({emitEvent: false});
}
private observeCreateRelationZonesChanges(): void {
this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges
.pipe(takeUntilDestroyed())
@ -275,29 +284,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit
});
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private levelsRequired(): ValidatorFn {
return (control: FormControl) => {
return control.value.length ? null : { levelsRequired: true };
};
}
private forbiddenNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenNames = ['ctx', 'e', 'pi'];
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
levelsFormArray(): UntypedFormArray {
return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray;
}

92
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html

@ -77,48 +77,52 @@
</mat-form-field>
</div>
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [(expanded)]="filterExpanded"
[disabled]="!metricForm.get('allowFilter').value">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="allowFilter"
(click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.filter-hint' | translate }}">
{{ 'calculated-fields.metrics.filter' | translate }}
@if (!simpleMode) {
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [(expanded)]="filterExpanded"
[disabled]="!metricForm.get('allowFilter').value">
<mat-expansion-panel-header class="flex flex-row flex-wrap">
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-stretch justify-center" formControlName="allowFilter"
(click)="$event.stopPropagation()">
<div tb-hint-tooltip-icon="{{ 'calculated-fields.metrics.filter-hint' | translate }}">
{{ 'calculated-fields.metrics.filter' | translate }}
</div>
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-js-func required
formControlName="filter"
functionName="filter"
[functionArgs]="functionArgs"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="highlightRules"
[editorCompleter]="editorCompleter"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/filter_expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-js-func required
formControlName="filter"
functionName="filter"
[functionArgs]="functionArgs"
[disableUndefinedCheck]="true"
[scriptLanguage]="ScriptLanguage.TBEL"
[highlightRules]="highlightRules"
[editorCompleter]="editorCompleter"
[helpPopupStyle]="{ width: '1200px' }"
helpId="calculated-field/filter_expression_fn">
<div toolbarPrefixButton
class="tb-primary-background tbel-script-lang-chip">{{ 'api-usage.tbel' | translate }}
</div>
</tb-js-func>
</ng-template>
</mat-expansion-panel>
</div>
<ng-container formGroupName="input">
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (inputType of AggInputTypes; track inputType) {
<mat-option [value]="inputType">{{ AggInputTypeTranslations.get(inputType) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</tb-js-func>
</ng-template>
</mat-expansion-panel>
</div>
}
<ng-container formGroupName="input">
@if (!simpleMode) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.metrics.value-source' | translate }}</div>
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic">
<mat-select formControlName="type">
@for (inputType of AggInputTypes; track inputType) {
<mat-option [value]="inputType">{{ AggInputTypeTranslations.get(inputType) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
@if (this.metricForm.get('input.type').value === AggInputType.key) {
<div class="tb-form-row">
<div class="fixed-title-width tb-required">{{ 'calculated-fields.argument-name' | translate }}</div>
@ -156,6 +160,14 @@
</tb-js-func>
}
</ng-container>
@if (simpleMode) {
<div class="tb-form-row">
<div class="fixed-title-width">{{ 'calculated-fields.default-value' | translate }}</div>
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<input matInput autocomplete="off" type="number" name="value" formControlName="defaultValue" placeholder="{{ 'action.set' | translate }}"/>
</mat-form-field>
</div>
}
</div>
<div class="tb-config-panel-buttons">
<button mat-button

41
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts

@ -16,14 +16,17 @@
import { Component, Input, OnInit, output } from '@angular/core';
import { TbPopoverComponent } from '@shared/components/popover.component';
import { FormBuilder, FormControl, ValidatorFn, Validators } from '@angular/forms';
import { FormBuilder, Validators } from '@angular/forms';
import { charsWithNumRegex } from '@shared/models/regex.constants';
import {
AggFunction,
AggFunctionTranslations,
AggInputType,
AggInputTypeTranslations,
CalculatedFieldAggMetricValue
CalculatedFieldAggMetricValue,
FORBIDDEN_NAMES,
forbiddenNamesValidator,
uniqueNameValidator
} from '@shared/models/calculated-field.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EntityFilter } from '@shared/models/query/query.models';
@ -46,6 +49,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
@Input() metric: CalculatedFieldAggMetricValue;
@Input() usedNames: string[];
@Input() arguments: Array<string>;
@Input() simpleMode: boolean;
@Input() editorCompleter: TbEditorCompleter;
@Input() highlightRules: AceHighlightRules;
@ -54,7 +58,7 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
functionArgs: Array<string>
metricForm = this.fb.group({
name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]],
function: [AggFunction.AVG],
allowFilter: [false],
filter: ['', Validators.required],
@ -62,7 +66,8 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
type: [AggInputType.key],
key: ['', Validators.required],
function: ['', Validators.required],
})
}),
defaultValue: [null]
});
entityFilter: EntityFilter;
@ -83,6 +88,8 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
}
ngOnInit(): void {
this.updatedForm();
const data: CalculatedFieldAggMetricValuePanel = {
...this.metric,
allowFilter: !!this.metric.filter,
@ -109,6 +116,15 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
this.popover.hide();
}
private updatedForm(): void {
this.metricForm.get('name').addValidators(uniqueNameValidator(this.usedNames));
this.metricForm.get('name').updateValueAndValidity({emitEvent: false});
if (!this.simpleMode) {
this.metricForm.removeControl('defaultValue', {emitEvent: false});
}
}
private observeFilterAllowChange(): void {
this.metricForm.get('allowFilter').valueChanges
.pipe(takeUntilDestroyed())
@ -147,21 +163,4 @@ export class CalculatedFieldMetricsPanelComponent implements OnInit {
this.metricForm.get('input.key').markAsTouched();
}
}
private uniqNameRequired(): ValidatorFn {
return (control: FormControl) => {
const newName = control.value.trim().toLowerCase();
const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName);
return isDuplicate ? { duplicateName: true } : null;
};
}
private forbiddenNameValidator(): ValidatorFn {
return (control: FormControl) => {
const trimmedValue = control.value.trim().toLowerCase();
const forbiddenNames = ['ctx', 'e', 'pi'];
return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null;
};
}
}

20
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.html → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.html

@ -20,10 +20,16 @@
<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/5 xs:!w-full sm:!w-1/2">
<mat-header-cell mat-sort-header *matHeaderCellDef class="xs:!w-full sm:!w-1/2" [class]="{
'!w-1/5': !simpleMode,
'!w-1/2': simpleMode,
}">
<div tbTruncateWithTooltip>{{ 'calculated-fields.metrics.metric-name' | translate }}</div>
</mat-header-cell>
<mat-cell *matCellDef="let metric" class="argument-name-cell w-1/5 xs:w-full sm:w-1/2">
<mat-cell *matCellDef="let metric" class="argument-name-cell xs:w-full sm:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
<div class="flex items-center">
<div tbTruncateWithTooltip class="flex-1">{{ metric.name }}</div>
<tb-copy-button class="copy-argument-name"
@ -35,10 +41,16 @@
</mat-cell>
</ng-container>
<ng-container [matColumnDef]="'function'">
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/5 xs:hidden lt-md:w-1/2">
<mat-header-cell mat-sort-header *matHeaderCellDef class="xs:hidden lt-md:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
{{ 'calculated-fields.metrics.aggregation' | translate }}
</mat-header-cell>
<mat-cell *matCellDef="let metric" class="w-1/5 xs:hidden lt-md:w-1/2">
<mat-cell *matCellDef="let metric" class="xs:hidden lt-md:w-1/2" [class]="{
'w-1/5': !simpleMode,
'w-1/2': simpleMode,
}">
<div tbTruncateWithTooltip>{{ AggFunctionTranslations.get(metric.function) | translate }}</div>
</mat-cell>
</ng-container>

18
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component.ts → ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts

@ -16,11 +16,13 @@
import {
AfterViewInit,
booleanAttribute,
ChangeDetectorRef,
Component,
DestroyRef,
forwardRef,
Input,
OnInit,
Renderer2,
ViewChild,
ViewContainerRef,
@ -51,7 +53,7 @@ import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import {
CalculatedFieldMetricsPanelComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component';
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component';
import { TbEditorCompleter } from '@shared/models/ace/completion.models';
import { AceHighlightRules } from '@shared/models/ace/ace.models';
@ -72,11 +74,12 @@ import { AceHighlightRules } from '@shared/models/ace/ace.models';
}
],
})
export class CalculatedFieldMetricsTableComponent implements ControlValueAccessor, Validator, AfterViewInit {
export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValueAccessor, Validator, AfterViewInit {
@Input() arguments: Array<string>;
@Input() editorCompleter: TbEditorCompleter;
@Input() highlightRules: AceHighlightRules;
@Input({transform: booleanAttribute}) simpleMode: boolean = false;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@ -85,7 +88,7 @@ export class CalculatedFieldMetricsTableComponent implements ControlValueAccesso
sortOrder = { direction: 'asc' as SortDirection, property: '' };
dataSource = new CalculatedFieldMetricsDatasource();
displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions']
displayColumns = ['name', 'function', 'filter', 'valueSource', 'actions'];
readonly AggFunctionTranslations = AggFunctionTranslations;
readonly AggInputTypeTranslations = AggInputTypeTranslations;
@ -109,6 +112,12 @@ export class CalculatedFieldMetricsTableComponent implements ControlValueAccesso
});
}
ngOnInit() {
if (this.simpleMode) {
this.displayColumns = ['name', 'function', 'actions'];
}
}
ngAfterViewInit(): void {
this.sort.sortChange.asObservable().pipe(
takeUntilDestroyed(this.destroyRef)
@ -155,7 +164,8 @@ export class CalculatedFieldMetricsTableComponent implements ControlValueAccesso
usedNames: this.metricsFormArray.value.map(({ name }) => name).filter(name => name !== metric.name),
arguments: this.arguments,
editorCompleter: this.editorCompleter,
highlightRules: this.highlightRules
highlightRules: this.highlightRules,
simpleMode: this.simpleMode,
};
this.popoverComponent = this.popoverService.displayPopover({
trigger,

42
ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module.ts

@ -0,0 +1,42 @@
///
/// 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 { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { SharedModule } from '@shared/shared.module';
import {
CalculatedFieldMetricsTableComponent
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component';
import {
CalculatedFieldMetricsPanelComponent
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component';
@NgModule({
imports: [
CommonModule,
SharedModule,
],
declarations: [
CalculatedFieldMetricsTableComponent,
CalculatedFieldMetricsPanelComponent
],
exports: [
CalculatedFieldMetricsTableComponent
]
})
export class CalculatedFieldMetricsTableModule {
}

4
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html

@ -17,7 +17,7 @@
-->
<div class="tb-form-panel" [formGroup]="outputForm">
<div class="tb-form-panel-title">{{ 'calculated-fields.output' | translate }}</div>
<div class="flex flex-col gap-3">
<div [class]=containerInputClass>
<div class="flex gap-3 xs:flex-col">
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ 'calculated-fields.output-type' | translate }}</mat-label>
@ -44,7 +44,7 @@
</div>
@if (simpleMode) {
@if (hiddenName) {
<div class="grid grid-cols-2 items-start gap-3 xs:grid-cols-1">
<div class="grid items-start gap-3 has-[.simpleMode]:grid-cols-2 xs:grid-cols-1">
<ng-container *ngTemplateOutlet="decimalsByDefaultField"></ng-container>
<ng-content select=".simpleMode"></ng-content>
</div>

13
ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts

@ -63,6 +63,13 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
@coerceBoolean()
hiddenName = false;
@Input()
@coerceBoolean()
disableType = false;
@Input()
containerInputClass: string | string[] | Record<string, boolean | undefined | null> = 'flex flex-col gap-3';
@Input({required: true})
entityId: EntityId;
@ -141,6 +148,9 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
if (this.simpleMode && 'name' in value) {
value.name = value.name?.trim() ?? '';
}
if (this.disableType) {
value.type = this.outputForm.get('type').value;
}
this.propagateChange(value);
}
@ -163,5 +173,8 @@ export class CalculatedFieldOutputComponent implements ControlValueAccessor, Val
} else {
this.outputForm.get('decimalsByDefault').disable({emitEvent: false});
}
if (this.disableType) {
this.outputForm.get('type').disable({emitEvent: false});
}
}
}

1
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.html

@ -60,6 +60,7 @@
[editorCompleter]="argumentsEditorCompleter$ | async"
></tb-calculated-field-metrics-table>
<tb-time-unit-input required
sameWidthInputs
appearance="outline"
subscriptSizing="dynamic"
labelText="{{ 'calculated-fields.deduplication-interval' | translate }}"

10
ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module.ts

@ -27,11 +27,8 @@ import {
RelatedEntitiesAggregationComponentComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component';
import {
CalculatedFieldMetricsTableComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-table.component';
import {
CalculatedFieldMetricsPanelComponent
} from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component';
CalculatedFieldMetricsTableModule
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module';
@NgModule({
imports: [
@ -39,11 +36,10 @@ import {
SharedModule,
CalculatedFieldOutputModule,
CalculatedFieldArgumentsTableModule,
CalculatedFieldMetricsTableModule,
],
declarations: [
RelatedEntitiesAggregationComponentComponent,
CalculatedFieldMetricsTableComponent,
CalculatedFieldMetricsPanelComponent
],
exports: [
RelatedEntitiesAggregationComponentComponent,

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

Loading…
Cancel
Save