Browse Source

Merge branch 'master' of github.com:thingsboard/thingsboard into feature/cf-output

pull/14225/head
IrynaMatveieva 11 months ago
parent
commit
3372f0a669
  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. 6
      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. 52
      application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java
  27. 9
      application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java
  28. 1
      application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
  29. 2
      application/src/main/java/org/thingsboard/server/service/system/SystemInfoService.java
  30. 272
      application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java
  31. 40
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  32. 35
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  33. 6
      application/src/main/resources/thingsboard.yml
  34. 1
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  35. 255
      application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java
  36. 55
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  37. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java
  38. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java
  39. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java
  40. 2
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java
  41. 3
      application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java
  42. 4
      application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java
  43. 1
      application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java
  44. 410
      application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java
  45. 3
      application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java
  46. 1
      application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java
  47. 6
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java
  48. 40
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java
  49. 11
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java
  50. 39
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java
  51. 39
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java
  52. 5
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java
  53. 65
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java
  54. 19
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java
  55. 16
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java
  56. 64
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java
  57. 24
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java
  58. 64
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java
  59. 23
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java
  60. 63
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java
  61. 21
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java
  62. 17
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java
  63. 2
      common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java
  64. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  65. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  66. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  67. 1
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java
  68. 96
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java
  69. 67
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java
  70. 29
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java
  71. 108
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java
  72. 68
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java
  73. 47
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java
  74. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java
  75. 47
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java
  76. 53
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java
  77. 31
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java
  78. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java
  79. 49
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java
  80. 51
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java
  81. 2
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  82. 128
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java
  83. 168
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java
  84. 9
      common/proto/src/main/proto/queue.proto
  85. 2
      common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java
  86. 23
      common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java
  87. 23
      dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java
  88. 42
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java
  89. 563
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java
  90. 2
      transport/coap/src/main/resources/tb-coap-transport.yml
  91. 2
      transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml
  92. 1
      ui-ngx/src/app/core/auth/auth.models.ts
  93. 1
      ui-ngx/src/app/core/auth/auth.reducer.ts
  94. 1
      ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html
  95. 4
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts
  96. 70
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html
  97. 38
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts
  98. 9
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts
  99. 71
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts
  100. 18
      ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.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

@ -42,7 +42,11 @@ import org.thingsboard.server.common.data.cf.configuration.OutputStrategy;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy;
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.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -66,6 +70,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 org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.util.Collections;
@ -76,6 +81,7 @@ import java.util.Objects;
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;
@ -87,7 +93,10 @@ import static org.thingsboard.server.dao.util.KvUtils.filterChangedAttr;
import static org.thingsboard.server.dao.util.KvUtils.toTsKvEntryList;
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
@ -122,6 +131,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));
@ -155,19 +165,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());
@ -208,6 +220,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);
@ -307,17 +330,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) {
@ -343,6 +375,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;
@ -41,6 +43,8 @@ public interface CalculatedFieldProcessingService {
void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback);
ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval);
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()) {

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

@ -25,6 +25,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;
@ -49,6 +50,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 org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.util.ArrayList;
@ -130,6 +132,10 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
}
@Override
public ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) {
return super.fetchMetricDuringInterval(tenantId, entityId, argKey, metric, interval);
}
public void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
if (result instanceof AlarmCalculatedFieldResult) {
sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfIds));

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()) {

52
application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java

@ -17,7 +17,6 @@ package org.thingsboard.server.service.install;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
@ -25,14 +24,13 @@ import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.util.List;
@Service
@Profile("install")
@Slf4j
@RequiredArgsConstructor
public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSettingsService {
// This list should include all versions which are compatible for the upgrade.
// The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release.
private static final List<String> SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1");
// This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.).
// The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release.
private static final List<String> SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0");
private final ProjectInfo projectInfo;
private final JdbcTemplate jdbcTemplate;
@ -80,7 +78,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
@Override
public String getPackageSchemaVersion() {
if (packageSchemaVersion == null) {
packageSchemaVersion = projectInfo.getProjectVersion();
packageSchemaVersion = normalizeVersion(projectInfo.getProjectVersion());
}
return packageSchemaVersion;
}
@ -88,17 +86,28 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
@Override
public String getDbSchemaVersion() {
if (schemaVersionFromDb == null) {
Long version = getSchemaVersionFromDb();
if (version == null) {
Long dbVersion = getSchemaVersionFromDb();
if (dbVersion == null) {
onSchemaSettingsError("Upgrade failed: the database schema version is missing.");
}
@SuppressWarnings("DataFlowIssue")
long major = version / 1000000;
long minor = (version % 1000000) / 1000;
long patch = version % 1000;
schemaVersionFromDb = major + "." + minor + "." + patch;
long version = dbVersion;
if (version < 1_000_000_000) {
// Old format: MMM mmm ppp (e.g., 4002001 = 4.2.1)
long major = version / 1_000_000;
long minor = (version % 1_000_000) / 1000;
long maintenance = version % 1000;
schemaVersionFromDb = major + "." + minor + "." + maintenance + ".0";
} else {
// New format: MMM mmm mmm ppp (e.g., 4002001001 = 4.2.1.1)
long major = version / 1_000_000_000;
long minor = (version % 1_000_000_000) / 1_000_000;
long maintenance = (version % 1_000_000) / 1000;
long patch = version % 1000;
schemaVersionFromDb = major + "." + minor + "." + maintenance + "." + patch;
}
}
return schemaVersionFromDb;
}
@ -116,13 +125,26 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti
long major = Integer.parseInt(versionParts[0]);
long minor = Integer.parseInt(versionParts[1]);
long patch = versionParts.length > 2 ? Integer.parseInt(versionParts[2]) : 0;
long maintenance = Integer.parseInt(versionParts[2]);
long patch = Integer.parseInt(versionParts[3]);
return major * 1000000 + minor * 1000 + patch;
return major * 1_000_000_000L + minor * 1_000_000L + maintenance * 1000L + patch;
}
private void onSchemaSettingsError(String message) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> log.error(message)));
throw new RuntimeException(message);
}
private String normalizeVersion(String version) {
String[] parts = version.split("\\.");
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return major + "." + minor + "." + maintenance + "." + patch;
}
}

9
application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java

@ -65,9 +65,6 @@ import java.util.stream.Stream;
import static org.thingsboard.server.utils.LwM2mObjectModelUtils.toLwm2mResource;
/**
* Created by ashvayka on 18.04.18.
*/
@Component
@Slf4j
public class InstallScripts {
@ -134,6 +131,10 @@ public class InstallScripts {
return Paths.get(getDataDir(), JSON_DIR, EDGE_DIR, RULE_CHAINS_DIR);
}
public Path getWidgetTypesDir() {
return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
}
public String getDataDir() {
if (!StringUtils.isEmpty(dataDir)) {
if (!Paths.get(this.dataDir).toFile().isDirectory()) {
@ -237,7 +238,7 @@ public class InstallScripts {
}
);
}
Path widgetTypesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR);
Path widgetTypesDir = getWidgetTypesDir();
if (Files.exists(widgetTypesDir)) {
try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(JSON_EXT))) {
dirStream.forEach(

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)) {

2
application/src/main/java/org/thingsboard/server/service/system/SystemInfoService.java

@ -19,7 +19,9 @@ import org.thingsboard.server.common.data.FeaturesInfo;
import org.thingsboard.server.common.data.SystemInfo;
public interface SystemInfoService {
SystemInfo getSystemInfo();
FeaturesInfo getFeaturesInfo();
}

272
application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java

@ -0,0 +1,272 @@
/**
* 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.system;
import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.hash.Hashing;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.install.DatabaseSchemaSettingsService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.install.update.DefaultDataUpdateService;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
/**
* Runs at application startup and applies no-downtime data updates
* when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1).
*/
@Slf4j
@Component
@TbCoreComponent
@RequiredArgsConstructor
public class SystemPatchApplier {
private static final long ADVISORY_LOCK_ID = 7536891047216478431L;
private final JdbcTemplate jdbcTemplate;
private final InstallScripts installScripts;
private final DatabaseSchemaSettingsService schemaSettingsService;
private final WidgetTypeService widgetTypeService;
@PostConstruct
private void init() {
ExecutorService executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("system-patch-applier"));
executor.submit(() -> {
try {
applyPatchIfNeeded();
} catch (Exception e) {
log.error("Failed to apply system data patch updates", e);
} finally {
executor.shutdown();
}
});
}
private void applyPatchIfNeeded() {
boolean skipVersionCheck = DefaultDataUpdateService.getEnv("SKIP_PATCH_VERSION_CHECK", false);
if (!skipVersionCheck && !isVersionChanged()) {
return;
}
if (!acquireAdvisoryLock()) {
log.trace("Could not acquire advisory lock. Another node is processing patch updates.");
return;
}
try {
int updated = updateWidgetTypes();
log.info("Updated {} widget types", updated);
schemaSettingsService.updateSchemaVersion();
log.info("System data patch update completed successfully");
} finally {
releaseAdvisoryLock();
}
}
private boolean isVersionChanged() {
String packageVersion = schemaSettingsService.getPackageSchemaVersion();
String dbVersion = schemaSettingsService.getDbSchemaVersion();
log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion);
VersionInfo packageVersionInfo = parseVersion(packageVersion);
VersionInfo dbVersionInfo = parseVersion(dbVersion);
if (packageVersionInfo == null || dbVersionInfo == null) {
log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion);
return false;
}
if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) {
return false;
}
log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion);
return true;
}
private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) {
return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor
&& packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch;
}
private int updateWidgetTypes() {
AtomicInteger updated = new AtomicInteger();
Path widgetTypesDir = installScripts.getWidgetTypesDir();
if (!Files.exists(widgetTypesDir)) {
log.trace("Widget types directory does not exist: {}", widgetTypesDir);
return 0;
}
try (Stream<Path> dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) {
dirStream.forEach(
path -> {
try {
if (updateWidgetTypeFromFile(path)) {
updated.incrementAndGet();
}
} catch (Exception e) {
log.error("Unable to update widget type from json: [{}]", path.toString());
throw new RuntimeException("Unable to update widget type from json", e);
}
}
);
}
return updated.get();
}
private boolean updateWidgetTypeFromFile(Path filePath) {
JsonNode json = JacksonUtil.toJsonNode(filePath.toFile());
WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class);
String fqn = fileWidgetType.getFqn();
WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn);
if (existingWidgetType == null) {
// We expect only update here, so it's probably never happening, but for test purpose leave it like this:
throw new RuntimeException("Widget type not found: " + fqn);
}
if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) {
existingWidgetType.setDescription(fileWidgetType.getDescription());
existingWidgetType.setName(fileWidgetType.getName());
existingWidgetType.setDescriptor(fileWidgetType.getDescriptor());
widgetTypeService.saveWidgetType(existingWidgetType);
log.trace("Updated widget type: {}", fqn);
return true;
}
log.trace("Widget type unchanged: {}", fqn);
return false;
}
private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) {
if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) {
return true;
}
if (!Objects.equals(existing.getName(), file.getName())) {
return true;
}
return !Objects.equals(existing.getDescription(), file.getDescription());
}
private boolean isDescriptorEqual(JsonNode desc1, JsonNode desc2) {
if (desc1 == null && desc2 == null) {
return true;
}
if (desc1 == null || desc2 == null) {
return false;
}
try {
String hash1 = computeChecksum(desc1);
String hash2 = computeChecksum(desc2);
return Objects.equals(hash1, hash2);
} catch (Exception e) {
log.warn("Failed to compare descriptors using checksum, falling back to equals", e);
return desc1.equals(desc2);
}
}
private String computeChecksum(JsonNode node) {
String canonicalString = JacksonUtil.toCanonicalString(node);
if (canonicalString == null) {
return null;
}
return Hashing.sha256().hashBytes(canonicalString.getBytes()).toString();
}
private boolean acquireAdvisoryLock() {
try {
Boolean acquired = jdbcTemplate.queryForObject(
"SELECT pg_try_advisory_lock(?)",
Boolean.class,
ADVISORY_LOCK_ID
);
if (Boolean.TRUE.equals(acquired)) {
log.trace("Acquired advisory lock");
return true;
}
return false;
} catch (Exception e) {
log.error("Failed to acquire advisory lock", e);
return false;
}
}
private void releaseAdvisoryLock() {
try {
jdbcTemplate.queryForObject(
"SELECT pg_advisory_unlock(?)",
Boolean.class,
ADVISORY_LOCK_ID
);
log.debug("Released advisory lock");
} catch (Exception e) {
log.error("Failed to release advisory lock", e);
}
}
private VersionInfo parseVersion(String version) {
try {
String[] parts = version.split("\\.");
int major = Integer.parseInt(parts[0]);
int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0;
int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0;
int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0;
return new VersionInfo(major, minor, maintenance, patch);
} catch (Exception e) {
log.error("Failed to parse version: {}", version, e);
return null;
}
}
private Stream<Path> listDir(Path dir) {
try {
return Files.list(dir);
} catch (NoSuchFileException e) {
return Stream.empty();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public record VersionInfo(int major, int minor, int maintenance, int patch) {}
}

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

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

@ -31,6 +31,11 @@ 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.TimeSeriesOutput;
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;
@ -44,6 +49,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;
@ -165,6 +171,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");
@ -236,6 +270,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;
@ -280,6 +315,26 @@ 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)));
config.setOutput(new TimeSeriesOutput());
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

3
application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java

@ -37,9 +37,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* @author Andrew Shvayka
*/
@TestPropertySource(properties = {
"transport.http.enabled=true",
"transport.http.max_payload_size=/api/v1/*/rpc/**=10000;/api/v1/**=20000"

4
application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java

@ -45,10 +45,6 @@ import java.util.concurrent.TimeoutException;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* @author Illia Barkov
*/
@Slf4j
public abstract class BaseRestApiLimitsTest extends AbstractControllerTest {

1
application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java

@ -29,7 +29,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@Slf4j
public class RestTemplateConvertersTest {

410
application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java

@ -0,0 +1,410 @@
/**
* 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.system;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.WidgetTypeId;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.dao.widget.WidgetTypeService;
import org.thingsboard.server.service.install.InstallScripts;
import org.thingsboard.server.service.system.SystemPatchApplier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
public class SystemPatchApplierTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private InstallScripts installScripts;
@Mock
private WidgetTypeService widgetTypeService;
@InjectMocks
private SystemPatchApplier reconciler;
@TempDir
Path tempDir;
@ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}")
@CsvSource({
"4.2.1, 4, 2, 1, 0",
"4.2.0, 4, 2, 0, 0",
"4.2, 4, 2, 0, 0",
"4.0.1.2, 4, 0, 1, 2",
"4, 4, 0, 0, 0",
"1.0.5.7, 1, 0, 5, 7",
"10.20.30.40, 10, 20, 30, 40",
"0.0.1, 0, 0, 1, 0"
})
void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) {
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString);
assertNotNull(version, "Version should not be null for: " + versionString);
assertEquals(expectedMajor, version.major(), "Major version mismatch");
assertEquals(expectedMinor, version.minor(), "Minor version mismatch");
assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch");
assertEquals(expectedPatch, version.patch(), "Patch version mismatch");
}
@ParameterizedTest(name = "Parse invalid version: {0}")
@CsvSource({
"invalid",
"a.b.c",
"1.2.y.x",
"''",
"1.x.3"
})
void testParseInvalidVersion(String invalidVersion) {
SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion);
assertNull(version, "Version should be null for invalid input: " + invalidVersion);
}
@Test
void whenLockIsNotAcquired_thenAcquiredIsSuccess() {
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true);
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock");
assertEquals(Boolean.TRUE, acquired);
verify(jdbcTemplate).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong());
}
@Test
void whenLockIsAlreadyAcquired_thenAcquiredIsFailed() {
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(false);
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock");
assertNotEquals(Boolean.TRUE, acquired);
}
@Test
void testReleaseAdvisoryLock() {
when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong()))
.thenReturn(true);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
verify(jdbcTemplate).queryForObject(
contains("pg_advisory_unlock"), eq(Boolean.class), anyLong());
}
@Test
void whenWidgetNotFound_thenThrowException() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget");
String json = JacksonUtil.toString(testWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null);
assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"));
}
@Test
void whenDescriptorChanged_thenUpdateTheExistingWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget");
fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}"));
String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget");
existingWidget.setId(new WidgetTypeId(UUID.randomUUID()));
existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated);
verify(widgetTypeService).saveWidgetType(argThat(w ->
w.getDescriptor().get("version").asInt() == 2
));
}
@Test
void whenNameChanged_thenUpdateTheExistingWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "New Name");
String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Old Name");
existingWidget.setId(new WidgetTypeId(UUID.randomUUID()));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(1, updated);
verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName())));
}
@Test
void whenNothingChanged_thenSkipTheUpdateOfTheExistingWidget() throws Exception {
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget");
String json = JacksonUtil.toString(fileWidget);
assertNotNull(json);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), json);
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget");
existingWidget.setId(new WidgetTypeId(UUID.randomUUID()));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget"))
.thenReturn(existingWidget);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
assertEquals(0, updated);
verify(widgetTypeService, never()).saveWidgetType(any());
}
@ParameterizedTest(name = "{0}")
@MethodSource("provideDescriptorComparisonTestCases")
void testIfDescriptorsAreEqual(String testName, JsonNode desc1, JsonNode desc2, boolean expectedEqual) {
Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isDescriptorEqual", desc1, desc2);
assertEquals(expectedEqual, result, testName);
}
@Test
void whenDescriptorChanged_thenReturnWidgetTypeChanged() {
WidgetTypeDetails existing = createTestWidgetType("test", "Test");
existing.setDescriptor(JacksonUtil.toJsonNode("{\"version\":1}"));
WidgetTypeDetails file = createTestWidgetType("test", "Test");
file.setDescriptor(JacksonUtil.toJsonNode("{\"version\":2}"));
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file));
assertTrue(result);
}
@Test
void whenNameChanged_thenReturnWidgetTypeChanged() {
WidgetTypeDetails existing = createTestWidgetType("test", "Old Name");
WidgetTypeDetails file = createTestWidgetType("test", "New Name");
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file));
assertTrue(result);
}
@Test
void whenDescriptionChanged_thenReturnWidgetTypeChanged() {
WidgetTypeDetails existing = createTestWidgetType("test", "Test");
existing.setDescription("Old description");
WidgetTypeDetails file = createTestWidgetType("test", "Test");
file.setDescription("New description");
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file));
assertTrue(result);
}
@Test
void whenWidgetTypeAreIdentical_thenNoUpdateIsPerformed() {
WidgetTypeDetails existing = createTestWidgetType("test", "Test");
WidgetTypeDetails file = createTestWidgetType("test", "Test");
boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file));
assertFalse(result);
}
@Test
void whenLockIsHeldByOneThread_thenSecondThreadCannotAcquireLock() throws Exception {
CountDownLatch lockAcquired = new CountDownLatch(1);
CountDownLatch startSecondThread = new CountDownLatch(1);
CountDownLatch testComplete = new CountDownLatch(1);
AtomicBoolean firstThreadAcquiredLock = new AtomicBoolean(false);
AtomicBoolean secondThreadAcquiredLock = new AtomicBoolean(false);
AtomicBoolean firstThreadSavedWidget = new AtomicBoolean(false);
AtomicBoolean secondThreadSavedWidget = new AtomicBoolean(false);
Path widgetTypesDir = tempDir.resolve("widget_types");
Files.createDirectories(widgetTypesDir);
when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir);
WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget");
fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}"));
String toString = JacksonUtil.toCanonicalString(fileWidget);
assertNotNull(toString);
Files.writeString(widgetTypesDir.resolve("test_widget.json"), toString);
WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget");
existingWidget.setId(new WidgetTypeId(UUID.randomUUID()));
existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"));
when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(existingWidget);
when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()))
.thenReturn(true)
.thenReturn(false);
when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong()))
.thenReturn(true);
// The first thread-acquires lock and performs update
Thread firstThread = new Thread(() -> {
try {
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock");
firstThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (firstThreadAcquiredLock.get()) {
lockAcquired.countDown();
startSecondThread.await(5, TimeUnit.SECONDS);
// Simulate work while holding lock
Thread.sleep(100);
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
firstThreadSavedWidget.set(updated != null && updated > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
} catch (Exception ignored) {
} finally {
testComplete.countDown();
}
});
// Second thread - attempts to acquire lock but fails
Thread secondThread = new Thread(() -> {
try {
lockAcquired.await(5, TimeUnit.SECONDS);
startSecondThread.countDown();
Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock");
secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired));
if (secondThreadAcquiredLock.get()) {
Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes");
secondThreadSavedWidget.set(updated != null && updated > 0);
ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock");
}
} catch (Exception ignored) {}
});
firstThread.start();
secondThread.start();
assertTrue(testComplete.await(10, TimeUnit.SECONDS), "Test should complete within timeout");
firstThread.join(1000);
secondThread.join(1000);
assertTrue(firstThreadAcquiredLock.get(), "First thread should acquire lock");
assertFalse(secondThreadAcquiredLock.get(), "Second thread should NOT acquire lock");
assertTrue(firstThreadSavedWidget.get(), "First thread should save widget");
assertFalse(secondThreadSavedWidget.get(), "Second thread should NOT save widget");
verify(widgetTypeService, times(1)).saveWidgetType(any());
}
private static Stream<Arguments> provideDescriptorComparisonTestCases() {
return Stream.of(
Arguments.of("Both null", null, null, true),
Arguments.of("First null", null, JacksonUtil.newObjectNode(), false),
Arguments.of("Second null", JacksonUtil.newObjectNode(), null, false),
Arguments.of("Same content",
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"),
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"),
true),
Arguments.of("Different content",
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"),
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}"),
false),
Arguments.of("Different key order but same content",
JacksonUtil.toJsonNode("{\"version\":1,\"type\":\"latest\"}"),
JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"),
true),
Arguments.of("Empty objects",
JacksonUtil.toJsonNode("{}"),
JacksonUtil.toJsonNode("{}"),
true)
);
}
private WidgetTypeDetails createTestWidgetType(String fqn, String name) {
WidgetTypeDetails widget = new WidgetTypeDetails();
widget.setFqn(fqn);
widget.setName(name);
widget.setDescription("Test description");
widget.setTenantId(TenantId.SYS_TENANT_ID);
widget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\"}"));
return widget;
}
}

3
application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java

@ -18,9 +18,6 @@ package org.thingsboard.server.system.sql;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.system.BaseHttpDeviceApiTest;
/**
* Created by Valerii Sosliuk on 6/27/2017.
*/
@DaoSqlTest
public class DeviceApiSqlTest extends BaseHttpDeviceApiTest {
}

1
application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java

@ -18,7 +18,6 @@ package org.thingsboard.server.system.sql;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.system.BaseRestApiLimitsTest;
@DaoSqlTest
public class RestApiLimitsSqlTest extends BaseRestApiLimitsTest {
}

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
common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java

@ -83,6 +83,12 @@ public class JacksonUtil {
.addModule(new Jdk8Module())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.build();
public static final ObjectMapper CANONICAL_JSON_MAPPER = JsonMapper.builder()
.addModule(new Jdk8Module())
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.serializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL)
.build();
public static ObjectMapper getObjectMapperWithJavaTimeModule() {
return JsonMapper.builder()
@ -207,6 +213,23 @@ public class JacksonUtil {
return data;
}
public static String toCanonicalString(Object value) {
try {
if (value == null) {
return null;
}
if (value instanceof JsonNode) {
Object pojo = CANONICAL_JSON_MAPPER.convertValue(value, Object.class);
return CANONICAL_JSON_MAPPER.writeValueAsString(pojo);
}
return CANONICAL_JSON_MAPPER.writeValueAsString(value);
} catch (Exception e) {
throw new IllegalArgumentException("The given Json object value cannot be transformed to a canonical String: " + value, e);
}
}
public static <T> T treeToValue(JsonNode node, Class<T> clazz) {
try {
return OBJECT_MAPPER.treeToValue(node, clazz);

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

42
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java

@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.profile;
import com.google.gson.JsonElement;
import com.google.gson.JsonParser;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.TbContext;
@ -29,9 +30,14 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.device.profile.AlarmCondition;
import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey;
import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType;
import org.thingsboard.server.common.data.device.profile.AlarmConditionSpec;
import org.thingsboard.server.common.data.device.profile.AlarmConditionSpecType;
import org.thingsboard.server.common.data.device.profile.AlarmRule;
import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm;
import org.thingsboard.server.common.data.device.profile.DurationAlarmConditionSpec;
import org.thingsboard.server.common.data.exception.ApiUsageLimitsExceededException;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
@ -39,6 +45,8 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.DynamicValueSourceType;
import org.thingsboard.server.common.data.query.EntityKey;
import org.thingsboard.server.common.data.query.EntityKeyType;
import org.thingsboard.server.common.data.rule.RuleNodeState;
@ -56,6 +64,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT;
import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_ACK;
@ -88,6 +97,10 @@ class DeviceState {
this.deviceId = deviceId;
this.deviceProfile = deviceProfile;
if (hasDurationRulesWithDynamicValueFromCurrentDevice(deviceProfile)) {
latestValues = fetchLatestValues(ctx, deviceId);
}
this.dynamicPredicateValueCtx = new DynamicPredicateValueCtxImpl(ctx.getTenantId(), deviceId, ctx);
if (config.isPersistAlarmRulesState()) {
@ -117,7 +130,10 @@ class DeviceState {
public void updateProfile(TbContext ctx, DeviceProfile deviceProfile) throws ExecutionException, InterruptedException {
Set<AlarmConditionFilterKey> oldKeys = Set.copyOf(this.deviceProfile.getEntityKeys());
this.deviceProfile.updateDeviceProfile(deviceProfile);
if (latestValues != null) {
if (latestValues == null && hasDurationRulesWithDynamicValueFromCurrentDevice(this.deviceProfile)) {
latestValues = fetchLatestValues(ctx, deviceId);
} else if (latestValues != null) {
Set<AlarmConditionFilterKey> keysToFetch = new HashSet<>(this.deviceProfile.getEntityKeys());
keysToFetch.removeAll(oldKeys);
if (!keysToFetch.isEmpty()) {
@ -135,10 +151,31 @@ class DeviceState {
}
}
private static boolean hasDurationRulesWithDynamicValueFromCurrentDevice(ProfileState deviceProfile) {
return deviceProfile.getAlarmSettings().stream().anyMatch(DeviceState::isDurationRuleWithDynamicValueFromCurrentDevice);
}
private static boolean isDurationRuleWithDynamicValueFromCurrentDevice(DeviceProfileAlarm alarm) {
return Stream.concat(alarm.getCreateRules().values().stream(), Stream.ofNullable(alarm.getClearRule()))
.map(AlarmRule::getCondition)
.map(AlarmCondition::getSpec)
.anyMatch(spec -> isDurationRule(spec) && hasDynamicDurationValueFromCurrentDevice((DurationAlarmConditionSpec) spec));
}
private static boolean isDurationRule(AlarmConditionSpec spec) {
return spec instanceof DurationAlarmConditionSpec durationSpec && durationSpec.getType() == AlarmConditionSpecType.DURATION;
}
private static boolean hasDynamicDurationValueFromCurrentDevice(DurationAlarmConditionSpec spec) {
DynamicValue<Long> dynamicValue = spec.getPredicate().getDynamicValue();
return dynamicValue != null && dynamicValue.getSourceType() == DynamicValueSourceType.CURRENT_DEVICE;
}
public void harvestAlarms(TbContext ctx, long ts) throws ExecutionException, InterruptedException {
log.debug("[{}] Going to harvest alarms: {}", ctx.getSelfId(), ts);
boolean stateChanged = false;
for (AlarmState state : alarmStates.values()) {
state.setDataSnapshot(latestValues);
stateChanged |= state.process(ctx, ts);
}
if (persistState && stateChanged) {
@ -348,7 +385,8 @@ class DeviceState {
return EntityKeyType.ATTRIBUTE;
}
private DataSnapshot fetchLatestValues(TbContext ctx, EntityId originator) throws ExecutionException, InterruptedException {
@SneakyThrows
private DataSnapshot fetchLatestValues(TbContext ctx, EntityId originator) {
Set<AlarmConditionFilterKey> entityKeysToFetch = deviceProfile.getEntityKeys();
DataSnapshot result = new DataSnapshot(entityKeysToFetch);
addEntityKeysToSnapshot(ctx, originator, entityKeysToFetch, result);

563
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java

File diff suppressed because it is too large

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,

1
ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html

@ -225,6 +225,7 @@
additionalClass="tb-suffix-show-on-hover"
appearance="outline"
panelWidth=""
showInlineError
required
[label]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name': 'ai-models.model-id') | translate"
[errorText]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name-required': 'ai-models.model-id-required') | translate"

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" 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>

38
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);
@ -168,7 +175,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI
saveArgument(): void {
const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue;
if (this.entityType === ArgumentEntityType.Owner) {
value.refDynamicSourceConfiguration.type = ArgumentEntityType.Owner;
value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner};
} else if (this.entityType === ArgumentEntityType.Tenant) {
value.refEntityId = new TenantId(this.tenantId) as any;
}
@ -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;
}
}

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

@ -31,8 +31,13 @@ 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 { isDefined } from '@core/utils';
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';
@Component({
@ -88,20 +93,21 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa
this.displayColumns = ['name', 'type', 'key', 'actions'];
this.panelAdditionalCtx = {
argumentEntityTypes: [ArgumentEntityType.Current],
isOutputKey: true
isOutputKey: true,
forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'],
};
}
}
protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean {
if (!this.isScript && isDefined(argument?.refEntityId)) {
if (!this.isScript && (isDefined(argument?.refEntityId) || isDefined(argument?.refDynamicSourceConfiguration))) {
return false;
}
return super.isEditButtonShowBadge(argument);
}
protected updateErrorText(): void {
if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId))) {
if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId) || isDefined(control.value.refDynamicSourceConfiguration))) {
this.errorText = 'calculated-fields.hint.arguments-propagate-argument-entity-type';
} else if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) {
this.errorText = 'calculated-fields.hint.arguments-propagate-arguments-with-rolling';
@ -109,6 +115,8 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa
this.errorText = 'calculated-fields.hint.arguments-entity-not-found';
} else if (!this.argumentsFormArray.controls.length) {
this.errorText = 'calculated-fields.hint.arguments-empty';
} if (this.isScript && !this.argumentsFormArray.controls.some(control => isUndefinedOrNull(control.value?.refEntityId) && isUndefinedOrNull(control.value.refDynamicSourceConfiguration))) {
this.errorText = 'calculated-fields.hint.arguments-propagate-argument-must-current-entity';
} else {
this.errorText = '';
}

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

Loading…
Cancel
Save