Browse Source

Merge remote-tracking branch 'upstream/feature/entity-alarm-rules' into feature/propagation-cf

pull/14208/head
dshvaika 10 months ago
parent
commit
bf32a4ba3d
  1. 2
      application/src/main/data/resources/dashboards/gateways_dashboard.json
  2. 8
      application/src/main/data/upgrade/basic/schema_update.sql
  3. 3
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityActor.java
  4. 223
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  5. 143
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  6. 52
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldRelationActionMsg.java
  7. 11
      application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
  8. 14
      application/src/main/java/org/thingsboard/server/controller/AuthController.java
  9. 1
      application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java
  10. 2
      application/src/main/java/org/thingsboard/server/controller/TenantController.java
  11. 47
      application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java
  12. 50
      application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java
  13. 41
      application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java
  14. 52
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  15. 2
      application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java
  16. 14
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  17. 39
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java
  18. 8
      application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java
  19. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  20. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
  21. 13
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  22. 155
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  23. 6
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  24. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  25. 47
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java
  26. 188
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java
  27. 86
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java
  28. 58
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AggEntry.java
  29. 47
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.java
  30. 55
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/BaseAggEntry.java
  31. 41
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountAggEntry.java
  32. 43
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountUniqueAggEntry.java
  33. 41
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java
  34. 41
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java
  35. 43
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java
  36. 2
      application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java
  37. 11
      application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java
  38. 11
      application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java
  39. 2
      application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java
  40. 23
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java
  41. 13
      application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java
  42. 24
      application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java
  43. 22
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java
  44. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java
  45. 49
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java
  46. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java
  47. 2
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java
  48. 8
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
  49. 32
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java
  50. 22
      application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
  51. 2
      application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
  52. 13
      application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java
  53. 28
      application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java
  54. 2
      application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java
  55. 4
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  56. 2
      application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java
  57. 2
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  58. 45
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  59. 2
      application/src/main/resources/thingsboard.yml
  60. 49
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  61. 786
      application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java
  62. 22
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  63. 45
      application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
  64. 23
      application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
  65. 152
      application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java
  66. 74
      application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java
  67. 100
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java
  68. 2
      application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java
  69. 1
      application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java
  70. 43
      application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java
  71. 2
      application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java
  72. 174
      application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java
  73. 72
      application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java
  74. 92
      application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java
  75. 92
      application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java
  76. 32
      application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java
  77. 37
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java
  78. 29
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.java
  79. 37
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java
  80. 39
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.java
  81. 29
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.java
  82. 31
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.java
  83. 50
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java
  84. 13
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java
  85. 4
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java
  86. 8
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java
  87. 6
      application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java
  88. 5
      common/cluster-api/src/main/java/org/thingsboard/server/cluster/TbClusterService.java
  89. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java
  90. 1
      common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java
  91. 6
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  92. 1
      common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java
  93. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  94. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java
  95. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  96. 20
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunction.java
  97. 34
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunctionInput.java
  98. 38
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java
  99. 34
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggKeyInput.java
  100. 31
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java

2
application/src/main/data/resources/dashboards/gateways_dashboard.json

@ -650,7 +650,7 @@
"settings": {
"useMarkdownTextFunction": true,
"markdownTextPattern": "# Markdown/HTML card \\n - **Current entity**: **${entityName}**. \\n - **Current value**: **${Random}**.",
"markdownTextFunction": "var blockData = '';\nvar connectorsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action => action.name == \"Connectors\");\nvar logsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action => action.name == \"Logs\");\nfunction generateMatHeader(index) {\n if (index !== undefined && index > -1) {\n return `<mat-card-header class='tb-home-widget-link' (click)=\"ctx.actionsApi.handleWidgetAction($event, ctx.actionsApi.getActionDescriptors('elementClick')[${index}], ctx.datasources[0].entity.id)\">`\n } else {\n return \"<mat-card-header>\"\n }\n}\nfunction createDataBlock(value, label, dividerStyle, mobile, index) {\n blockData += `\n <mat-card style=\"flex-grow: 1; width: ${mobile ? '100%' : 'auto'}; min-height: ${mobile ? 'auto' : '57px'}\" class=\"${dividerStyle}\">\n <div class=\"divider\"></div>\n <mat-divider vertical style=\"height:100%\"></mat-divider>\n ${generateMatHeader(index)}\n <mat-card-subtitle>${label}</mat-card-subtitle>\n </mat-card-header>\n <mat-card-content> ${value}</mat-card-content>\n </mat-card>`;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\" ? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\nif (data[0].Version) {\n createDataBlock(data[0].Version, \"Gateway Version\", '');\n}\ncreateDataBlock(data[0].Type, \"Gateway Type\", '');\ncreateDataBlock(\n `<span style=\"color:rgb(25,128,56)\">${(data[1] ? data[1].count : 0)} </span>`\n + \" | \" +\n `<span style=\"color:rgb(203,37,48)\">${(data[2] ? data[2][\"count 2\"] : 0)} </span>`\n , \"Devices <span class='tb-hint' style='padding-left: 0'>(Active | Inactive)</span>\", '');\ncreateDataBlock(\n `<span style=\"color:rgb(25,128,56)\">${(data[0].active_connectors ? JSON.parse(data[0].active_connectors).length : 0)} </span>`\n + \" | \" +\n `<span style=\"color:rgb(203,37,48)\">${(data[0].inactive_connectors ? JSON.parse(data[0].inactive_connectors).length : 0)} </span>`\n , \"Connectors <span class='tb-hint' style='padding-left: 0'>(Enabled | Disabled)</span>\", '', '', connectorsIndex);\ncreateDataBlock(data[0].ALL_ERRORS_COUNT || 0, \"Errors\", (data[0].ALL_ERRORS_COUNT || 0) === 0 ? 'divider-green' : 'divider-red', '', logsIndex);\nreturn `<div class=\"flex flex-row flex-wrap gap-2 cards-container\">${blockData}</div>`;",
"markdownTextFunction": "var blockData = '';\nvar connectorsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action => action.name == \"Connectors\");\nvar logsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action => action.name == \"Logs\");\nfunction generateMatHeader(index) {\n if (index !== undefined && index > -1) {\n return `<mat-card-header class='tb-home-widget-link' (click)=\"ctx.actionsApi.handleWidgetAction($event, ctx.actionsApi.getActionDescriptors('elementClick')[${index}], ctx.datasources[0].entity.id)\">`\n } else {\n return \"<mat-card-header>\"\n }\n}\nfunction createDataBlock(value, label, dividerStyle, mobile, index) {\n blockData += `\n <mat-card style=\"flex-grow: 1; width: ${mobile ? '100%' : 'auto'}; min-height: ${mobile ? 'auto' : '57px'}\" class=\"${dividerStyle}\">\n <div class=\"divider\"></div>\n <mat-divider vertical style=\"height:100%\"></mat-divider>\n ${generateMatHeader(index)}\n <mat-card-subtitle>${label}</mat-card-subtitle>\n </mat-card-header>\n <mat-card-content> ${ctx.sanitizer.sanitize(1, value)}</mat-card-content>\n </mat-card>`;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\" ? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\nif (data[0].Version) {\n createDataBlock(data[0].Version, \"Gateway Version\", '');\n}\ncreateDataBlock(data[0].Type, \"Gateway Type\", '');\ncreateDataBlock(\n `<span style=\"color:rgb(25,128,56)\">${(data[1] ? data[1].count : 0)} </span>`\n + \" | \" +\n `<span style=\"color:rgb(203,37,48)\">${(data[2] ? data[2][\"count 2\"] : 0)} </span>`\n , \"Devices <span class='tb-hint' style='padding-left: 0'>(Active | Inactive)</span>\", '');\ncreateDataBlock(\n `<span style=\"color:rgb(25,128,56)\">${(data[0].active_connectors ? JSON.parse(data[0].active_connectors).length : 0)} </span>`\n + \" | \" +\n `<span style=\"color:rgb(203,37,48)\">${(data[0].inactive_connectors ? JSON.parse(data[0].inactive_connectors).length : 0)} </span>`\n , \"Connectors <span class='tb-hint' style='padding-left: 0'>(Enabled | Disabled)</span>\", '', '', connectorsIndex);\ncreateDataBlock(data[0].ALL_ERRORS_COUNT || 0, \"Errors\", (data[0].ALL_ERRORS_COUNT || 0) === 0 ? 'divider-green' : 'divider-red', '', logsIndex);\nreturn `<div class=\"flex flex-row flex-wrap gap-2 cards-container\">${blockData}</div>`;",
"applyDefaultMarkdownStyle": false,
"markdownCss": ".divider {\n position: absolute;\n width: 3px;\n top: 8px;\n border-radius: 2px;\n bottom: 8px;\n border: 1px solid rgba(31, 70, 144, 1);\n background-color: rgba(31, 70, 144, 1);\n left: 10px;\n}\n.divider-green .divider {\n border: 1px solid rgb(25,128,56);\n background-color: rgb(25,128,56);\n}\n\n.divider-green .mat-mdc-card-content {\n color: rgb(25,128,56);\n}\n\n.divider-red .divider {\n border: 1px solid rgb(203,37,48);\n background-color: rgb(203,37,48);\n}\n\n.divider-red .mat-mdc-card-content {\n color: rgb(203,37,48);\n}\n\n.mdc-card {\n position: relative;\n padding-left: 10px;\n margin-bottom: 1px;\n}\n\n.mat-mdc-card-subtitle {\n font-weight: 400;\n font-size: 12px;\n}\n\n.mat-mdc-card-header {\n padding: 8px 16px 0;\n}\n\n.mat-mdc-card-content:last-child {\n padding-bottom: 8px;\n font-size: 16px;\n}\n\n.cards-container {\n height: calc(100% - 1px);\n justify-content: stretch;\n align-items: center;\n margin-bottom: 1px;\n}\n\n::ng-deep.tb-home-widget-link > div {\n flex-grow: 1;\n cursor: pointer;\n}\n\n .tb-home-widget-link {\n width: 100%;\n }\n\n .tb-home-widget-link:hover::after{\n color: inherit;\n }\n \n .tb-home-widget-link::after{\n content: 'arrow_forward';\n display: inline-block;\n transform: rotate(315deg);\n font-family: 'Material Icons';\n font-weight: normal;\n font-style: normal;\n font-size: 18px;\n color: rgba(0, 0, 0, 0.12);\n vertical-align: bottom;\n margin-left: 6px;\n}"
},

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

@ -40,6 +40,12 @@ SET profile_data = jsonb_set(
WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
THEN NULL
ELSE to_jsonb(100)
END,
'minAllowedDeduplicationIntervalInSecForCF',
CASE
WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
THEN NULL
ELSE to_jsonb(3600)
END
)
),
@ -51,6 +57,8 @@ WHERE NOT (
(profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
AND
(profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
AND
(profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
);
-- UPDATE TENANT PROFILE CONFIGURATION END

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

@ -73,6 +73,9 @@ public class CalculatedFieldEntityActor extends AbstractCalculatedFieldActor {
case CF_ENTITY_DELETE_MSG:
processor.process((CalculatedFieldEntityDeleteMsg) msg);
break;
case CF_RELATION_ACTION_MSG:
processor.process((CalculatedFieldRelationActionMsg) msg);
break;
case CF_ENTITY_TELEMETRY_MSG:
processor.process((EntityCalculatedFieldTelemetryMsg) msg);
break;

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

@ -52,6 +52,7 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
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;
@ -122,6 +123,9 @@ 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);
@ -188,7 +192,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
}
public void process(CalculatedFieldEntityDeleteMsg msg) {
public void process(CalculatedFieldEntityDeleteMsg msg) throws CalculatedFieldException {
log.debug("[{}] Processing CF entity delete msg.", msg.getEntityId());
if (this.entityId.equals(msg.getEntityId())) {
if (states.isEmpty()) {
@ -209,6 +213,68 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
}
public void process(CalculatedFieldRelationActionMsg msg) throws CalculatedFieldException {
log.debug("[{}] Processing CF {} related entity msg.", msg.getRelatedEntityId(), msg.getAction());
switch (msg.getAction()) {
case UPDATED -> handleRelationUpdate(msg);
case DELETED -> handleRelationDelete(msg);
default -> msg.getCallback().onSuccess();
}
}
private void handleRelationUpdate(CalculatedFieldRelationActionMsg msg) throws CalculatedFieldException {
CalculatedFieldCtx ctx = msg.getCalculatedField();
var callback = new MultipleTbCallback(CALLBACKS_PER_CF, msg.getCallback());
var state = states.get(ctx.getCfId());
try {
Map<String, ArgumentEntry> updatedArgs = new HashMap<>();
if (state == null) {
state = createState(ctx);
} else {
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) {
Map<String, ArgumentEntry> fetchedArgs = cfService.fetchArgsFromDb(tenantId, msg.getRelatedEntityId(), ctx.getArguments());
updatedArgs = relatedEntitiesAggState.updateEntityData(setEntityIdToSingleEntityArguments(msg.getRelatedEntityId(), fetchedArgs));
}
state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize());
}
if (state.isSizeOk()) {
processStateIfReady(state, updatedArgs, ctx, Collections.singletonList(ctx.getCfId()), null, null, callback);
} else {
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build();
}
} catch (Exception e) {
log.debug("[{}][{}] Failed to initialize CF state", entityId, ctx.getCfId(), e);
if (e instanceof CalculatedFieldException cfe) {
throw cfe;
}
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
}
}
private void handleRelationDelete(CalculatedFieldRelationActionMsg msg) throws CalculatedFieldException {
CalculatedFieldCtx ctx = msg.getCalculatedField();
CalculatedFieldId cfId = ctx.getCfId();
CalculatedFieldState state = states.get(cfId);
if (state == null) {
msg.getCallback().onSuccess();
return;
}
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState aggState) {
aggState.cleanupEntityData(msg.getRelatedEntityId());
state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize());
if (state.isSizeOk()) {
processStateIfReady(state, Collections.emptyMap(), ctx, Collections.singletonList(ctx.getCfId()), null, null, msg.getCallback());
} else {
throw new RuntimeException(ctx.getSizeExceedsLimitMessage());
}
} else {
msg.getCallback().onSuccess();
}
}
public void process(EntityCalculatedFieldTelemetryMsg msg) throws CalculatedFieldException {
log.trace("[{}] Processing CF telemetry msg: {}", msg.getEntityId(), msg);
var proto = msg.getProto();
@ -239,7 +305,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
} else if (proto.getAttrDataCount() > 0) {
processArgumentValuesUpdate(ctx, cfIds, callback, mapToArguments(ctx, msg.getEntityId(), proto.getScope(), proto.getAttrDataList()), toTbMsgId(proto), toTbMsgType(proto));
} else if (proto.getRemovedTsKeysCount() > 0) {
processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto));
processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithFetchedValue(ctx, msg.getEntityId(), proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto));
} else if (proto.getRemovedAttrKeysCount() > 0) {
processArgumentValuesUpdate(ctx, cfIds, callback, mapToArgumentsWithDefaultValue(ctx, msg.getEntityId(), proto.getScope(), proto.getRemovedAttrKeysList()), toTbMsgId(proto), toTbMsgType(proto));
} else {
@ -315,7 +381,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
private void processRemovedTelemetry(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List<CalculatedFieldId> cfIdList, TbCallback callback) throws CalculatedFieldException {
processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithFetchedValue(ctx, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto));
processArgumentValuesUpdate(ctx, cfIdList, callback, mapToArgumentsWithFetchedValue(ctx, entityId, proto.getRemovedTsKeysList()), toTbMsgId(proto), toTbMsgType(proto));
}
private void processRemovedAttributes(CalculatedFieldCtx ctx, CalculatedFieldTelemetryMsgProto proto, List<CalculatedFieldId> cfIdList, TbCallback callback) throws CalculatedFieldException {
@ -469,109 +535,164 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, List<TsKvProto> data) {
return mapToArguments(ctx.getMainEntityArguments(), data);
return mapToArguments(entityId, ctx.getMainEntityArguments(), Collections.emptyMap(), data);
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, List<TsKvProto> data) {
return mapToArguments(ctx.getLinkedAndDynamicArgs(entityId), data);
return mapToArguments(entityId, ctx.getLinkedAndDynamicArgs(entityId), ctx.getRelatedEntityArguments(), data);
}
private Map<String, ArgumentEntry> mapToArguments(Map<ReferencedEntityKey, String> argNames, List<TsKvProto> data) {
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
private Map<String, ArgumentEntry> mapToArguments(EntityId originator, Map<ReferencedEntityKey, Set<String>> args, Map<ReferencedEntityKey, Set<String>> relatedEntityArgs, List<TsKvProto> data) {
Map<String, ArgumentEntry> arguments = new HashMap<>();
for (TsKvProto item : data) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null);
String argName = argNames.get(key);
if (argName != null) {
arguments.put(argName, new SingleValueArgumentEntry(item));
}
key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null);
argName = argNames.get(key);
if (argName != null) {
arguments.put(argName, new SingleValueArgumentEntry(item));
if (!relatedEntityArgs.isEmpty() || !args.isEmpty()) {
for (TsKvProto item : data) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_LATEST, null);
Set<String> argNames = relatedEntityArgs.get(key);
if (argNames != null) {
argNames.forEach(argName -> {
arguments.put(argName, new SingleValueArgumentEntry(originator, item));
});
}
argNames = args.get(key);
if (argNames != null) {
argNames.forEach(argName -> {
arguments.put(argName, new SingleValueArgumentEntry(item));
});
}
key = new ReferencedEntityKey(item.getKv().getKey(), ArgumentType.TS_ROLLING, null);
argNames = args.get(key);
if (argNames != null) {
argNames.forEach(argName -> {
arguments.put(argName, new SingleValueArgumentEntry(item));
});
}
}
}
return arguments;
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, attrDataList);
return mapToArguments(entityId, ctx.getMainEntityArguments(), ctx.getMainEntityGeofencingArgumentNames(), Collections.emptyMap(), scope, attrDataList);
}
private Map<String, ArgumentEntry> mapToArguments(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
var argNames = ctx.getLinkedAndDynamicArgs(entityId);
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
var args = ctx.getLinkedAndDynamicArgs(entityId);
var relatedEntityArgs = ctx.getRelatedEntityArguments();
List<String> geofencingArgumentNames = ctx.getLinkedEntityAndCurrentOwnerGeofencingArgumentNames();
return mapToArguments(entityId, argNames, geofencingArgumentNames, scope, attrDataList);
return mapToArguments(entityId, args, geofencingArgumentNames, relatedEntityArgs, scope, attrDataList);
}
private Map<String, ArgumentEntry> mapToArguments(EntityId entityId, Map<ReferencedEntityKey, String> argNames, List<String> geofencingArgNames, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
private Map<String, ArgumentEntry> mapToArguments(EntityId entityId, Map<ReferencedEntityKey, Set<String>> args, List<String> geofencingArgNames, Map<ReferencedEntityKey, Set<String>> relatedEntityArgs, AttributeScopeProto scope, List<AttributeValueProto> attrDataList) {
if (args.isEmpty() && relatedEntityArgs.isEmpty()) {
return Collections.emptyMap();
}
Map<String, ArgumentEntry> arguments = new HashMap<>();
for (AttributeValueProto item : attrDataList) {
ReferencedEntityKey key = new ReferencedEntityKey(item.getKey(), ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
String argName = argNames.get(key);
if (argName == null) {
continue;
Set<String> argNames = relatedEntityArgs.get(key);
if (argNames != null) {
argNames.forEach(argName -> {
arguments.put(argName, new SingleValueArgumentEntry(entityId, item));
});
}
if (geofencingArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry(entityId, item));
argNames = args.get(key);
if (argNames == null) {
continue;
}
arguments.put(argName, new SingleValueArgumentEntry(item));
argNames.forEach(argName -> {
if (geofencingArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry(entityId, item));
} else {
arguments.put(argName, new SingleValueArgumentEntry(item));
}
});
}
return arguments;
}
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, EntityId entityId, AttributeScopeProto scope, List<String> removedAttrKeys) {
var argNames = ctx.getLinkedAndDynamicArgs(entityId);
if (argNames.isEmpty()) {
return Collections.emptyMap();
}
var args = ctx.getLinkedAndDynamicArgs(entityId);
var relatedEntityArgs = ctx.getRelatedEntityArguments();
List<String> geofencingArgumentNames = ctx.getLinkedEntityAndCurrentOwnerGeofencingArgumentNames();
return mapToArgumentsWithDefaultValue(argNames, ctx.getArguments(), geofencingArgumentNames, scope, removedAttrKeys);
return mapToArgumentsWithDefaultValue(entityId, args, ctx.getArguments(), geofencingArgumentNames, relatedEntityArgs, scope, removedAttrKeys);
}
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(CalculatedFieldCtx ctx, AttributeScopeProto scope, List<String> removedAttrKeys) {
return mapToArgumentsWithDefaultValue(ctx.getMainEntityArguments(), ctx.getArguments(), ctx.getMainEntityGeofencingArgumentNames(), scope, removedAttrKeys);
return mapToArgumentsWithDefaultValue(null, ctx.getMainEntityArguments(), ctx.getArguments(), ctx.getMainEntityGeofencingArgumentNames(), Collections.emptyMap(), scope, removedAttrKeys);
}
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(Map<ReferencedEntityKey, String> argNames, Map<String, Argument> configArguments, List<String> geofencingArgNames, AttributeScopeProto scope, List<String> removedAttrKeys) {
private Map<String, ArgumentEntry> mapToArgumentsWithDefaultValue(EntityId msgEntityId,
Map<ReferencedEntityKey, Set<String>> args,
Map<String, Argument> configArguments,
List<String> geofencingArgNames,
Map<ReferencedEntityKey, Set<String>> relatedEntityArgs,
AttributeScopeProto scope,
List<String> removedAttrKeys) {
if (args.isEmpty() && relatedEntityArgs.isEmpty()) {
return Collections.emptyMap();
}
Map<String, ArgumentEntry> arguments = new HashMap<>();
for (String removedKey : removedAttrKeys) {
ReferencedEntityKey key = new ReferencedEntityKey(removedKey, ArgumentType.ATTRIBUTE, AttributeScope.valueOf(scope.name()));
String argName = argNames.get(key);
if (argName == null) {
continue;
Set<String> argNames = relatedEntityArgs.get(key);
if (argNames != null) {
argNames.forEach(argName -> {
String defaultValue = getDefaultValue(configArguments, argName);
SingleValueArgumentEntry argumentEntry = buildSingleValue(removedKey, defaultValue, System.currentTimeMillis());
arguments.put(argName, new SingleValueArgumentEntry(msgEntityId, argumentEntry));
});
}
if (geofencingArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry());
argNames = args.get(key);
if (argNames == null) {
continue;
}
Argument argument = configArguments.get(argName);
String defaultValue = (argument != null) ? argument.getDefaultValue() : null;
arguments.put(argName, StringUtils.isNotEmpty(defaultValue)
? new SingleValueArgumentEntry(System.currentTimeMillis(), new StringDataEntry(removedKey, defaultValue), null)
: new SingleValueArgumentEntry());
argNames.forEach(argName -> {
if (geofencingArgNames.contains(argName)) {
arguments.put(argName, new GeofencingArgumentEntry());
} else {
String defaultValue = getDefaultValue(configArguments, argName);
SingleValueArgumentEntry argumentEntry = buildSingleValue(removedKey, defaultValue, System.currentTimeMillis());
arguments.put(argName, new SingleValueArgumentEntry(argumentEntry));
}
});
}
return arguments;
}
private Map<String, ArgumentEntry> mapToArgumentsWithFetchedValue(CalculatedFieldCtx ctx, List<String> removedTelemetryKeys) {
private String getDefaultValue(Map<String, Argument> configArguments, String argNames) {
Argument argument = configArguments.get(argNames);
return argument != null ? argument.getDefaultValue() : null;
}
private SingleValueArgumentEntry buildSingleValue(String attrKey, String defaultValue, long ts) {
return StringUtils.isNotEmpty(defaultValue)
? new SingleValueArgumentEntry(ts, new StringDataEntry(attrKey, defaultValue), null)
: new SingleValueArgumentEntry();
}
private Map<String, ArgumentEntry> mapToArgumentsWithFetchedValue(CalculatedFieldCtx ctx, EntityId entityId, List<String> removedTelemetryKeys) {
Map<String, Argument> deletedArguments = ctx.getArguments().entrySet().stream()
.filter(entry -> removedTelemetryKeys.contains(entry.getValue().getRefEntityKey().getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
Map<String, ArgumentEntry> fetchedArgs = cfService.fetchArgsFromDb(tenantId, entityId, deletedArguments);
if (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(ctx.getCfType())) {
fetchedArgs = setEntityIdToSingleEntityArguments(entityId, fetchedArgs);
}
fetchedArgs.values().forEach(arg -> arg.setForceResetPrevious(true));
return fetchedArgs;
}
private Map<String, ArgumentEntry> setEntityIdToSingleEntityArguments(EntityId relatedEntityId, Map<String, ArgumentEntry> fetchedArgs) {
return fetchedArgs.entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
argEntry -> new SingleValueArgumentEntry(relatedEntityId, argEntry.getValue())
));
}
private static List<CalculatedFieldId> getCalculatedFieldIds(CalculatedFieldTelemetryMsgProto proto) {
List<CalculatedFieldId> cfIds = new LinkedList<>();
for (var cfId : proto.getPreviousCalculatedFieldsList()) {

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

@ -16,6 +16,7 @@
package org.thingsboard.server.actors.calculatedField;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.function.TriConsumer;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.actors.TbActorCtx;
@ -29,14 +30,22 @@ import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.audit.ActionType;
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.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.msg.CalculatedFieldStatePartitionRestoreMsg;
import org.thingsboard.server.common.msg.cf.CalculatedFieldCacheInitMsg;
import org.thingsboard.server.common.msg.cf.CalculatedFieldEntityLifecycleMsg;
@ -48,6 +57,7 @@ import org.thingsboard.server.dao.asset.AssetService;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.device.DeviceService;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.queue.settings.TbQueueCalculatedFieldSettings;
import org.thingsboard.server.service.cf.CalculatedFieldProcessingService;
import org.thingsboard.server.service.cf.CalculatedFieldStateService;
@ -69,6 +79,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import java.util.function.Function;
import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto;
@ -90,6 +101,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
private final DeviceService deviceService;
private final AssetService assetService;
private final CustomerService customerService;
private final RelationService relationService;
private final TbAssetProfileCache assetProfileCache;
private final TbDeviceProfileCache deviceProfileCache;
private final TenantEntityProfileCache entityProfileCache;
@ -107,6 +119,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
this.deviceService = systemContext.getDeviceService();
this.assetService = systemContext.getAssetService();
this.customerService = systemContext.getCustomerService();
this.relationService = systemContext.getRelationService();
this.assetProfileCache = systemContext.getAssetProfileCache();
this.deviceProfileCache = systemContext.getDeviceProfileCache();
this.entityProfileCache = new TenantEntityProfileCache();
@ -175,9 +188,14 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException {
log.debug("Processing entity lifecycle event: [{}] for entity: [{}]", msg.getData().getEvent(), msg.getData().getEntityId());
var entityType = msg.getData().getEntityId().getEntityType();
var event = msg.getData().getEvent();
if (ComponentLifecycleEvent.RELATION_UPDATED.equals(event) || ComponentLifecycleEvent.RELATION_DELETED.equals(event)) {
log.debug("Processing relation [{}] event from entity: [{}]", event, msg.getData().getEntityId());
onRelationChangedEvent(msg.getData(), msg.getCallback());
return;
}
log.debug("Processing entity lifecycle event: [{}] for entity: [{}]", event, msg.getData().getEntityId());
var entityType = msg.getData().getEntityId().getEntityType();
switch (entityType) {
case CALCULATED_FIELD -> {
switch (event) {
@ -233,6 +251,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
entityProfileCache.add(profileId, entityId);
}
updateEntityOwner(entityId);
if (!isMyPartition(entityId, callback)) {
return;
}
@ -284,6 +303,55 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
}
}
private void onRelationChangedEvent(ComponentLifecycleMsg msg, TbCallback callback) {
Function<EntityId, TriConsumer<EntityId, CalculatedFieldCtx, TbCallback>> relationAction = switch (msg.getEvent()) {
case RELATION_UPDATED -> relatedId -> (entityId, ctx, cb) -> initRelatedEntity(entityId, relatedId, ctx, cb);
case RELATION_DELETED -> relatedId -> (entityId, ctx, cb) -> deleteRelatedEntity(entityId, relatedId, ctx, cb);
default -> null;
};
if (relationAction == null) {
callback.onSuccess();
return;
}
EntityRelation entityRelation = JacksonUtil.treeToValue(msg.getInfo(), EntityRelation.class);
EntityId toId = entityRelation.getTo();
EntityId fromId = entityRelation.getFrom();
String relationType = entityRelation.getType();
MultipleTbCallback callbackForToAndFrom = new MultipleTbCallback(2, callback);
processRelationByDirection(EntitySearchDirection.TO, relationType, toId, callbackForToAndFrom, relationAction.apply(fromId));
processRelationByDirection(EntitySearchDirection.FROM, relationType, fromId, callbackForToAndFrom, relationAction.apply(toId));
}
private void processRelationByDirection(EntitySearchDirection direction,
String relationType,
EntityId mainId,
MultipleTbCallback parentCallback,
TriConsumer<EntityId, CalculatedFieldCtx, TbCallback> relationAction) {
List<CalculatedFieldCtx> cfsByEntityIdAndProfile = getCalculatedFieldsByEntityIdAndProfile(mainId);
if (cfsByEntityIdAndProfile.isEmpty()) {
parentCallback.onSuccess();
return;
}
List<CalculatedFieldCtx> matchingCfs = cfsByEntityIdAndProfile.stream()
.filter(cf -> {
var config = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getCalculatedField().getConfiguration();
RelationPathLevel relation = config.getRelation();
return direction.equals(relation.direction()) && relationType.equals(relation.relationType());
})
.toList();
MultipleTbCallback directionCallback = new MultipleTbCallback(matchingCfs.size(), parentCallback);
matchingCfs.forEach(ctx ->
applyToTargetCfEntityActors(ctx, directionCallback, (entityId, cb) -> relationAction.accept(entityId, ctx, cb))
);
}
private void onCfCreated(ComponentLifecycleMsg msg, TbCallback callback) throws CalculatedFieldException {
var cfId = new CalculatedFieldId(msg.getEntityId().getId());
if (calculatedFields.containsKey(cfId)) {
@ -411,8 +479,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
public void onTelemetryMsg(CalculatedFieldTelemetryMsg msg) {
EntityId entityId = msg.getEntityId();
log.debug("Received telemetry msg from entity [{}]", entityId);
// 3 = 1 for CF processing + 1 for links processing + 1 for owner entity processing
MultipleTbCallback callback = new MultipleTbCallback(3, msg.getCallback());
// 4 = 1 for CF processing + 1 for links processing + 1 for owner entity processing + 1 for aggregation processing
MultipleTbCallback callback = new MultipleTbCallback(4, msg.getCallback());
// process all cfs related to entity, or it's profile;
var entityIdFields = getCalculatedFieldsByEntityId(entityId);
var profileIdFields = getCalculatedFieldsByEntityId(getProfileId(tenantId, entityId));
@ -441,6 +509,49 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
} else {
callback.onSuccess();
}
// process all aggregation cfs (if any);
List<CalculatedFieldEntityCtxId> aggregationCalculatedFields = filterAggregationCfs(msg);
if (!aggregationCalculatedFields.isEmpty()) {
cfExecService.pushMsgToLinks(msg, aggregationCalculatedFields, callback);
} else {
callback.onSuccess();
}
}
private List<CalculatedFieldEntityCtxId> filterAggregationCfs(CalculatedFieldTelemetryMsg msg) {
EntityId entityId = msg.getEntityId();
return calculatedFields.values().stream()
.filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getCfType()))
.filter(cf -> cf.relatedEntityMatches(msg.getProto()))
.flatMap(cf -> findRelationsForCf(entityId, cf).stream())
.toList();
}
private List<CalculatedFieldEntityCtxId> findRelationsForCf(EntityId entityId, CalculatedFieldCtx cf) {
List<CalculatedFieldEntityCtxId> result = new ArrayList<>();
if (cf.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration configuration) {
RelationPathLevel relation = configuration.getRelation();
EntitySearchDirection inverseDirection = switch (relation.direction()) {
case FROM -> EntitySearchDirection.TO;
case TO -> EntitySearchDirection.FROM;
};
RelationPathLevel inverseRelation = new RelationPathLevel(inverseDirection, relation.relationType());
List<EntityRelation> byRelationPathQuery = relationService.findByRelationPathQuery(tenantId, new EntityRelationPathQuery(entityId, List.of(inverseRelation)));
if (byRelationPathQuery != null && !byRelationPathQuery.isEmpty()) {
switch (relation.direction()) {
case FROM -> {
EntityRelation entityRelation = byRelationPathQuery.get(0); // only one supported
result.add(new CalculatedFieldEntityCtxId(tenantId, cf.getCfId(), entityRelation.getFrom()));
}
case TO -> {
byRelationPathQuery.stream()
.filter(entityRelation -> entityRelation.getTo().equals(cf.getEntityId()))
.forEach(entityRelation -> result.add(new CalculatedFieldEntityCtxId(tenantId, cf.getCfId(), entityRelation.getTo())));
}
}
}
}
return result;
}
public void onLinkedTelemetryMsg(CalculatedFieldLinkedTelemetryMsg msg) {
@ -467,9 +578,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
EntityId entityId = msg.getEntityId();
log.debug("Received changed owner msg from entity [{}]", entityId);
updateEntityOwner(entityId);
List<CalculatedFieldCtx> cfs = new ArrayList<>();
cfs.addAll(getCalculatedFieldsByEntityId(entityId));
cfs.addAll(getCalculatedFieldsByEntityId(getProfileId(tenantId, entityId)));
List<CalculatedFieldCtx> cfs = getCalculatedFieldsByEntityIdAndProfile(entityId);
if (cfs.isEmpty()) {
msgCallback.onSuccess();
return;
@ -533,6 +642,16 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
return result;
}
private List<CalculatedFieldCtx> getCalculatedFieldsByEntityIdAndProfile(EntityId entityId) {
List<CalculatedFieldCtx> cfsByEntityIdAndProfile = new ArrayList<>();
cfsByEntityIdAndProfile.addAll(getCalculatedFieldsByEntityId(entityId));
EntityId profileId = getProfileId(tenantId, entityId);
if (profileId != null) {
cfsByEntityIdAndProfile.addAll(getCalculatedFieldsByEntityId(profileId));
}
return cfsByEntityIdAndProfile;
}
private List<CalculatedFieldLink> getCalculatedFieldLinksByEntityId(EntityId entityId) {
if (entityId == null) {
return Collections.emptyList();
@ -560,6 +679,16 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware
getOrCreateActor(entityId).tell(msg);
}
private void deleteRelatedEntity(EntityId entityId, EntityId relatedEntityId, CalculatedFieldCtx cf, TbCallback callback) {
log.debug("Pushing delete related entity msg to specific actor [{}]", relatedEntityId);
getOrCreateActor(entityId).tell(new CalculatedFieldRelationActionMsg(tenantId, relatedEntityId, ActionType.DELETED, cf, callback));
}
private void initRelatedEntity(EntityId entityId, EntityId relatedEntityId, CalculatedFieldCtx cf, TbCallback callback) {
log.debug("Pushing init related entity msg to specific actor [{}]", relatedEntityId);
getOrCreateActor(entityId).tell(new CalculatedFieldRelationActionMsg(tenantId, relatedEntityId, ActionType.UPDATED, cf, callback));
}
private void deleteCfForEntity(EntityId entityId, CalculatedFieldId cfId, TbCallback callback) {
log.debug("Pushing delete CF msg to specific actor [{}]", entityId);
getOrCreateActor(entityId).tell(new CalculatedFieldEntityDeleteMsg(tenantId, cfId, callback));

52
application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldRelationActionMsg.java

@ -0,0 +1,52 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.actors.calculatedField;
import lombok.Data;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.MsgType;
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
@Data
public class CalculatedFieldRelationActionMsg implements ToCalculatedFieldSystemMsg {
private final TenantId tenantId;
private final EntityId relatedEntityId;
private final ActionType action;
private final CalculatedFieldCtx calculatedField;
private final TbCallback callback;
public CalculatedFieldRelationActionMsg(TenantId tenantId,
EntityId relatedEntityId, ActionType action,
CalculatedFieldCtx calculatedField,
TbCallback callback) {
this.tenantId = tenantId;
this.relatedEntityId = relatedEntityId;
this.action = action;
this.calculatedField = calculatedField;
this.callback = callback;
}
@Override
public MsgType getMsgType() {
return MsgType.CF_RELATION_ACTION_MSG;
}
}

11
application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java

@ -115,14 +115,9 @@ import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* @author Andrew Shvayka
*/
@Slf4j
public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor {
static final String SESSION_TIMEOUT_MESSAGE = "session timeout!";
final TenantId tenantId;
final DeviceId deviceId;
final LinkedHashMapRemoveEldest<UUID, SessionInfoMetaData> sessions;
@ -178,7 +173,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
private EdgeId findRelatedEdgeId() {
List<EntityRelation> result =
systemContext.getRelationService().findByToAndType(tenantId, deviceId, EntityRelation.CONTAINS_TYPE, RelationTypeGroup.EDGE);
if (result != null && result.size() > 0) {
if (result != null && !result.isEmpty()) {
EntityRelation relationToEdge = result.get(0);
if (relationToEdge.getFrom() != null && relationToEdge.getFrom().getId() != null) {
log.trace("[{}][{}] found edge [{}] for device", tenantId, deviceId, relationToEdge.getFrom().getId());
@ -501,7 +496,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
UUID sessionId = getSessionId(sessionInfo);
DeviceId deviceId = new DeviceId(new UUID(msg.getDeviceIdMSB(), msg.getDeviceIdLSB()));
ListenableFuture<Void> registrationFuture = systemContext.getClaimDevicesService()
.registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs());
.registerClaimingInfo(tenantId, deviceId, msg.getSecretKey(), msg.getDurationMs());
Futures.addCallback(registrationFuture, new FutureCallback<>() {
@Override
public void onSuccess(Void result) {
@ -723,7 +718,7 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
toDeviceRpcPendingMap.remove(requestId);
status = RpcStatus.FAILED;
response = JacksonUtil.newObjectNode().put("error", "There was a Timeout and all retry " +
"attempts have been exhausted. Retry attempts set: " + maxRpcRetries);
"attempts have been exhausted. Retry attempts set: " + maxRpcRetries);
}
} else {
md.setRetries(md.getRetries() + 1);

14
application/src/main/java/org/thingsboard/server/controller/AuthController.java

@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent;
import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent;
@ -48,7 +49,9 @@ import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
import org.thingsboard.server.service.security.auth.rest.RestAwareAuthenticationSuccessHandler;
import org.thingsboard.server.service.security.model.ActivateUserRequest;
import org.thingsboard.server.service.security.model.ChangePasswordRequest;
import org.thingsboard.server.service.security.model.ResetPasswordEmailRequest;
@ -74,7 +77,8 @@ public class AuthController extends BaseController {
private final SecuritySettingsService securitySettingsService;
private final RateLimitService rateLimitService;
private final ApplicationEventPublisher eventPublisher;
private final TwoFactorAuthService twoFactorAuthService;
private final RestAwareAuthenticationSuccessHandler authenticationSuccessHandler;
@ApiOperation(value = "Get current User (getUser)",
notes = "Get the information about the User which credentials are used to perform this REST API call.")
@ -221,7 +225,13 @@ public class AuthController extends BaseController {
}
}
var tokenPair = tokenFactory.createTokenPair(securityUser);
JwtPair tokenPair;
if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), user)) {
tokenPair = authenticationSuccessHandler.createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN);
} else {
tokenPair = tokenFactory.createTokenPair(securityUser);
}
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGIN, null);
return tokenPair;
}

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

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

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

@ -115,7 +115,7 @@ public class TenantController extends BaseController {
@ApiOperation(value = "Delete Tenant (deleteTenant)",
notes = "Deletes the tenant, it's customers, rule chains, devices and all other related entities. Referencing non-existing tenant Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH)
@PreAuthorize("hasAuthority('SYS_ADMIN')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@RequestMapping(value = "/tenant/{tenantId}", method = RequestMethod.DELETE)
@ResponseStatus(value = HttpStatus.OK)
public void deleteTenant(@Parameter(description = TENANT_ID_PARAM_DESCRIPTION)

47
application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java

@ -56,7 +56,6 @@ public class TwoFactorAuthConfigController extends BaseController {
private final TwoFaConfigManager twoFaConfigManager;
private final TwoFactorAuthService twoFactorAuthService;
@ApiOperation(value = "Get account 2FA settings (getAccountTwoFaSettings)",
notes = "Get user's account 2FA configuration. Configuration contains configs for different 2FA providers." + NEW_LINE +
"Example:\n" +
@ -67,13 +66,12 @@ public class TwoFactorAuthConfigController extends BaseController {
" }\n}\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@GetMapping("/account/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException {
SecurityUser user = getCurrentUser();
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null);
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user).orElse(null);
}
@ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)",
notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE +
"For TOTP, this will return a corresponding account config template " +
@ -99,7 +97,7 @@ public class TwoFactorAuthConfigController extends BaseController {
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config/generate")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", requiredMode = Schema.RequiredMode.REQUIRED))
@RequestParam TwoFaProviderType providerType) throws Exception {
SecurityUser user = getCurrentUser();
@ -127,7 +125,7 @@ public class TwoFactorAuthConfigController extends BaseController {
"or if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config/submit")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception {
SecurityUser user = getCurrentUser();
twoFactorAuthService.prepareVerificationCode(user, accountConfig, false);
@ -139,11 +137,11 @@ public class TwoFactorAuthConfigController extends BaseController {
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig,
@RequestParam(required = false) String verificationCode) throws Exception {
SecurityUser user = getCurrentUser();
if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) {
if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, accountConfig.getProviderType()).isPresent()) {
throw new IllegalArgumentException("2FA provider is already configured");
}
@ -154,7 +152,7 @@ public class TwoFactorAuthConfigController extends BaseController {
verificationSuccess = true;
}
if (verificationSuccess) {
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig);
} else {
throw new IllegalArgumentException("Verification code is incorrect");
}
@ -162,42 +160,41 @@ public class TwoFactorAuthConfigController extends BaseController {
@ApiOperation(value = "Update 2FA account config (updateTwoFaAccountConfig)", notes =
"Update config for a given provider type. \n" +
"Update request example:\n" +
"```\n{\n \"useByDefault\": true\n}\n```\n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
"Update request example:\n" +
"```\n{\n \"useByDefault\": true\n}\n```\n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PutMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType,
@RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException {
SecurityUser user = getCurrentUser();
TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType)
.orElseThrow(() -> new IllegalArgumentException("Config for " + providerType + " 2FA provider not found"));
accountConfig.setUseByDefault(updateRequest.isUseByDefault());
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig);
}
@ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes =
"Delete 2FA config for a given 2FA provider type. \n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@DeleteMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException {
SecurityUser user = getCurrentUser();
return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType);
return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user, providerType);
}
@ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes =
"Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" +
"Example of response:\n" +
"```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER
"Example of response:\n" +
"```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER
)
@GetMapping("/providers")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public List<TwoFaProviderType> getAvailableTwoFaProviders() throws ThingsboardException {
return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true)
.map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream()
@ -205,7 +202,6 @@ public class TwoFactorAuthConfigController extends BaseController {
.collect(Collectors.toList());
}
@ApiOperation(value = "Get platform 2FA settings (getPlatformTwoFaSettings)",
notes = "Get platform settings for 2FA. The settings are described for savePlatformTwoFaSettings API method. " +
"If 2FA is not configured, then an empty response will be returned." +
@ -260,11 +256,10 @@ public class TwoFactorAuthConfigController extends BaseController {
@PostMapping("/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
public PlatformTwoFaSettings savePlatformTwoFaSettings(@Parameter(description = "Settings value", required = true)
@RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException {
@RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException {
return twoFaConfigManager.savePlatformTwoFaSettings(getTenantId(), twoFaSettings);
}
@Data
public static class TwoFaAccountConfigUpdateRequest {
private boolean useByDefault;

50
application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java

@ -28,7 +28,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
@ -64,7 +63,6 @@ public class TwoFactorAuthController extends BaseController {
private final SystemSecurityService systemSecurityService;
private final UserService userService;
@ApiOperation(value = "Request 2FA verification code (requestTwoFaVerificationCode)",
notes = "Request 2FA verification code." + NEW_LINE +
"To make a request to this endpoint, you need an access token with the scope of PRE_VERIFICATION_TOKEN, " +
@ -92,30 +90,28 @@ public class TwoFactorAuthController extends BaseController {
SecurityUser user = getCurrentUser();
boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true);
if (verificationSuccess) {
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null);
user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal());
return tokenFactory.createTokenPair(user);
logLogInAction(servletRequest, user, null);
return createTokenPair(user);
} else {
ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error);
IllegalArgumentException error = new IllegalArgumentException("Verification code is incorrect");
logLogInAction(servletRequest, user, error);
throw error;
}
}
@ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes =
"Get the list of 2FA provider infos available for user to use. Example:\n" +
"```\n[\n" +
" {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" +
" {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" +
" {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" +
"]\n```")
"```\n[\n" +
" {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" +
" {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" +
" {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" +
"]\n```")
@GetMapping("/providers")
@PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')")
public List<TwoFaProviderInfo> getAvailableTwoFaProviders() throws ThingsboardException {
SecurityUser user = getCurrentUser();
Optional<PlatformTwoFaSettings> platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true);
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId())
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user)
.map(settings -> settings.getConfigs().values()).orElse(Collections.emptyList())
.stream().map(config -> {
String contact = null;
@ -139,6 +135,32 @@ public class TwoFactorAuthController extends BaseController {
.collect(Collectors.toList());
}
@ApiOperation(value = "Get regular token pair after successfully configuring 2FA",
notes = "Checks 2FA is configured, returning token pair on success.")
@PostMapping("/login")
@PreAuthorize("hasAuthority('MFA_CONFIGURATION_TOKEN')")
public JwtPair authenticateByTwoFaConfigurationToken(HttpServletRequest servletRequest) throws ThingsboardException {
SecurityUser user = getCurrentUser();
if (twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user)) {
logLogInAction(servletRequest, user, null);
return createTokenPair(user);
} else {
IllegalArgumentException error = new IllegalArgumentException("2FA is not configured");
logLogInAction(servletRequest, user, error);
throw error;
}
}
private JwtPair createTokenPair(SecurityUser user) {
log.debug("[{}][{}] Creating token pair for user", user.getTenantId(), user.getId());
user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal());
return tokenFactory.createTokenPair(user);
}
private void logLogInAction(HttpServletRequest servletRequest, SecurityUser user, Exception error) {
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error);
}
@Data
@AllArgsConstructor
@Builder

41
application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java

@ -15,7 +15,13 @@
*/
package org.thingsboard.server.service.ai;
import com.fasterxml.jackson.core.io.JsonStringEncoder;
import com.google.common.util.concurrent.FluentFuture;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.Content;
import dev.langchain4j.data.message.TextContent;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.ModelProvider;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ChatRequest;
import dev.langchain4j.model.chat.response.ChatResponse;
@ -24,6 +30,9 @@ import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig;
import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
class AiChatModelServiceImpl implements AiChatModelService {
@ -34,7 +43,39 @@ class AiChatModelServiceImpl implements AiChatModelService {
@Override
public <C extends AiChatModelConfig<C>> FluentFuture<ChatResponse> sendChatRequestAsync(AiChatModelConfig<C> chatModelConfig, ChatRequest chatRequest) {
ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer);
if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) {
chatRequest = prepareGithubChatRequest(chatRequest);
}
return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest);
}
private ChatRequest prepareGithubChatRequest(ChatRequest chatRequest) {
List<ChatMessage> messages = chatRequest.messages().stream()
.map(this::prepareUserMessage)
.collect(Collectors.toList());
return ChatRequest.builder()
.messages(messages)
.responseFormat(chatRequest.responseFormat())
.build();
}
private ChatMessage prepareUserMessage(ChatMessage message) {
if (message instanceof UserMessage userMessage) {
List<Content> newContents = userMessage.contents().stream()
.map(this::prepareContent)
.collect(Collectors.toList());
return UserMessage.from(newContents);
}
return message;
}
private Content prepareContent(Content content) {
if (content instanceof TextContent txt) {
return new TextContent(new String(JsonStringEncoder.getInstance().quoteAsString(txt.text())));
}
return content;
}
}

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

@ -27,6 +27,7 @@ import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.Aggregation;
@ -36,6 +37,9 @@ import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.ReadTsKvQuery;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.relation.RelationService;
@ -45,6 +49,7 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -92,6 +97,7 @@ public abstract class AbstractCalculatedFieldProcessingService {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCfType()) {
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts);
case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts);
case RELATED_ENTITIES_AGGREGATION -> fetchRelatedEntitiesAggArguments(ctx, entityId, ts);
};
if (ctx.getCfType() == PROPAGATION) {
argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId));
@ -165,6 +171,35 @@ public abstract class AbstractCalculatedFieldProcessingService {
return argFutures;
}
protected Map<String, ListenableFuture<ArgumentEntry>> fetchRelatedEntitiesAggArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
ListenableFuture<List<EntityId>> relatedEntitiesFut = resolveRelatedEntities(ctx.getTenantId(), entityId, aggConfig.getRelation());
return aggConfig.getArguments().entrySet().stream()
.collect(Collectors.toMap(
Map.Entry::getKey,
entry -> Futures.transformAsync(relatedEntitiesFut, relatedEntities -> fetchRelatedEntitiesArgumentEntry(ctx.getTenantId(), relatedEntities, entry.getValue(), ts), MoreExecutors.directExecutor())
));
}
private ListenableFuture<List<EntityId>> resolveRelatedEntities(TenantId tenantId, EntityId entityId, RelationPathLevel relation) {
ListenableFuture<List<EntityRelation>> relationsFut = relationService.findByRelationPathQueryAsync(tenantId, new EntityRelationPathQuery(entityId, List.of(relation)));
return Futures.transform(relationsFut, relations -> {
if (relations == null) {
return Collections.emptyList();
}
return switch (relation.direction()) {
case FROM -> relations.stream()
.map(EntityRelation::getTo)
.toList();
case TO -> relations.isEmpty() ? List.of() : List.of(relations.get(0).getFrom());
};
}, calculatedFieldCallbackExecutor);
}
private ListenableFuture<List<EntityId>> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Map.Entry<String, Argument> entry) {
Argument value = entry.getValue();
if (value.getRefEntityId() != null) {
@ -216,6 +251,23 @@ public abstract class AbstractCalculatedFieldProcessingService {
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))), MoreExecutors.directExecutor());
}
public ListenableFuture<ArgumentEntry> fetchRelatedEntitiesArgumentEntry(TenantId tenantId, List<EntityId> aggEntities, Argument argument, long startTs) {
List<ListenableFuture<Map.Entry<EntityId, ArgumentEntry>>> futures = aggEntities.stream()
.map(entityId -> {
ListenableFuture<ArgumentEntry> argumentEntryFut = fetchArgumentValue(tenantId, entityId, argument, startTs);
return Futures.transform(argumentEntryFut, argumentEntry -> Map.entry(entityId, ArgumentEntry.createSingleValueArgument(entityId, argumentEntry)), MoreExecutors.directExecutor());
})
.toList();
ListenableFuture<List<Map.Entry<EntityId, ? extends ArgumentEntry>>> allFutures = Futures.allAsList(futures);
return Futures.transform(allFutures,
entries -> ArgumentEntry.createAggArgument(
entries.stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))
),
MoreExecutors.directExecutor());
}
protected ListenableFuture<ArgumentEntry> fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) {
return switch (argument.getRefEntityKey().getType()) {
case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs);

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

@ -38,6 +38,8 @@ public interface CalculatedFieldCache {
List<CalculatedFieldCtx> getCalculatedFieldCtxsByEntityId(EntityId entityId);
List<CalculatedFieldCtx> getAggCalculatedFieldCtxsByFilter(Predicate<CalculatedFieldCtx> relatedEntityFilter);
boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter);
void addCalculatedField(TenantId tenantId, CalculatedFieldId calculatedFieldId);

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

@ -26,6 +26,7 @@ import org.thingsboard.server.actors.ActorSystemContext;
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.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@ -146,6 +147,15 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
.toList();
}
@Override
public List<CalculatedFieldCtx> getAggCalculatedFieldCtxsByFilter(Predicate<CalculatedFieldCtx> relatedEntityFilter) {
return calculatedFields.values().stream()
.filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getType()))
.map(cf -> getCalculatedFieldCtx(cf.getId()))
.filter(relatedEntityFilter)
.toList();
}
@Override
public boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter) {
List<CalculatedFieldCtx> entityCfs = getCalculatedFieldCtxsByEntityId(entityId);
@ -155,6 +165,10 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache {
}
}
return hasCalculatedFieldsByProfile(tenantId, entityId, filter);
}
public boolean hasCalculatedFieldsByProfile(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter) {
EntityId profileId = getProfileId(tenantId, entityId);
if (profileId != null) {
List<CalculatedFieldCtx> profileCfs = getCalculatedFieldCtxsByEntityId(profileId);

39
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.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
@ -35,7 +36,12 @@ import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntityRelationPathQuery;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.relation.RelationService;
import org.thingsboard.server.gen.transport.TransportProtos.AttributeScopeProto;
import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto;
@ -71,6 +77,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
private final CalculatedFieldCache calculatedFieldCache;
private final TbClusterService clusterService;
private final RelationService relationService;
@Override
public void pushRequestToQueue(TimeseriesSaveRequest request, TimeseriesSaveResult result, FutureCallback<Void> callback) {
@ -81,6 +88,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
cf -> cf.matches(entries),
cf -> cf.linkMatches(entityId, entries),
cf -> cf.dynamicSourceMatches(request.getEntries()),
cf -> cf.relatedEntityMatches(entries),
() -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
}
@ -99,6 +107,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
cf -> cf.matches(entries, scope),
cf -> cf.linkMatches(entityId, entries, scope),
cf -> cf.dynamicSourceMatches(request.getEntries(), request.getScope()),
cf -> cf.relatedEntityMatches(entries, scope),
() -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
}
@ -116,6 +125,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
cf -> cf.matchesKeys(result, scope),
cf -> cf.linkMatchesAttrKeys(entityId, result, scope),
cf -> cf.matchesDynamicSourceKeys(result, request.getScope()),
cf -> cf.matchesRelatedEntityKeys(result, scope),
() -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
}
@ -127,6 +137,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
cf -> cf.matchesKeys(result),
cf -> cf.linkMatchesTsKeys(entityId, result),
cf -> cf.matchesDynamicSourceKeys(result),
cf -> cf.matchesRelatedEntityKeys(result),
() -> toCalculatedFieldTelemetryMsgProto(request, result), callback);
}
@ -134,11 +145,12 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
Predicate<CalculatedFieldCtx> mainEntityFilter,
Predicate<CalculatedFieldCtx> linkedEntityFilter,
Predicate<CalculatedFieldCtx> dynamicSourceFilter,
Predicate<CalculatedFieldCtx> relatedEntityFilter,
Supplier<ToCalculatedFieldMsg> msg, FutureCallback<Void> callback) {
if (EntityType.TENANT.equals(entityId.getEntityType())) {
tenantId = (TenantId) entityId;
}
boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter, dynamicSourceFilter);
boolean send = checkEntityForCalculatedFields(tenantId, entityId, mainEntityFilter, linkedEntityFilter, dynamicSourceFilter, relatedEntityFilter);
if (send) {
ToCalculatedFieldMsg calculatedFieldMsg = msg.get();
clusterService.pushMsgToCalculatedFields(tenantId, entityId, calculatedFieldMsg, wrap(callback));
@ -149,7 +161,7 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
}
}
private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter, Predicate<CalculatedFieldCtx> linkedEntityFilter, Predicate<CalculatedFieldCtx> dynamicSourceFilter) {
private boolean checkEntityForCalculatedFields(TenantId tenantId, EntityId entityId, Predicate<CalculatedFieldCtx> filter, Predicate<CalculatedFieldCtx> linkedEntityFilter, Predicate<CalculatedFieldCtx> dynamicSourceFilter, Predicate<CalculatedFieldCtx> relatedEntityFilter) {
if (!CalculatedField.SUPPORTED_REFERENCED_ENTITIES.contains(entityId.getEntityType())) {
return false;
}
@ -176,6 +188,29 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS
}
}
List<CalculatedFieldCtx> cfCtxs = calculatedFieldCache.getAggCalculatedFieldCtxsByFilter(relatedEntityFilter);
for (CalculatedFieldCtx cfCtx : cfCtxs) {
if (cfCtx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) {
RelationPathLevel relation = aggConfig.getRelation();
EntitySearchDirection inverseDirection = switch (relation.direction()) {
case FROM -> EntitySearchDirection.TO;
case TO -> EntitySearchDirection.FROM;
};
RelationPathLevel inverseRelation = new RelationPathLevel(inverseDirection, relation.relationType());
List<EntityRelation> byRelationPathQuery = relationService.findByRelationPathQuery(tenantId, new EntityRelationPathQuery(entityId, List.of(inverseRelation)));
if (!byRelationPathQuery.isEmpty()) {
EntityId cfEntityId = cfCtx.getEntityId();
for (EntityRelation entityRelation : byRelationPathQuery) {
EntityId relatedId = (inverseDirection == EntitySearchDirection.FROM) ? entityRelation.getTo() : entityRelation.getFrom();
if (cfEntityId.equals(relatedId) || cfEntityId.equals(calculatedFieldCache.getProfileId(tenantId, relatedId))) {
return true;
}
}
return false;
}
}
}
return false;
}

8
application/src/main/java/org/thingsboard/server/service/cf/TelemetryCalculatedFieldResult.java

@ -39,6 +39,8 @@ public final class TelemetryCalculatedFieldResult implements CalculatedFieldResu
private final AttributeScope scope;
private final JsonNode result;
public static final TelemetryCalculatedFieldResult EMPTY = TelemetryCalculatedFieldResult.builder().result(null).build();
@Override
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) {
TbMsgType msgType = switch (type) {
@ -66,9 +68,9 @@ public final class TelemetryCalculatedFieldResult implements CalculatedFieldResu
@Override
public boolean isEmpty() {
return result == null || result.isMissingNode() || result.isNull() ||
(result.isObject() && result.isEmpty()) ||
(result.isArray() && result.isEmpty()) ||
(result.isTextual() && result.asText().isEmpty());
(result.isObject() && result.isEmpty()) ||
(result.isArray() && result.isEmpty()) ||
(result.isTextual() && result.asText().isEmpty());
}
}

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

@ -22,6 +22,7 @@ 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.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
@ -37,7 +38,8 @@ import java.util.Map;
@JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"),
@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 = PropagationArgumentEntry.class, name = "PROPAGATION"),
@JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES")
})
public interface ArgumentEntry {
@ -60,6 +62,10 @@ public interface ArgumentEntry {
return new SingleValueArgumentEntry(kvEntry);
}
static ArgumentEntry createSingleValueArgument(EntityId entityId, ArgumentEntry argumentEntry) {
return new SingleValueArgumentEntry(entityId, argumentEntry);
}
static ArgumentEntry createTsRollingArgument(List<TsKvEntry> kvEntries, int limit, long timeWindow) {
return new TsRollingArgumentEntry(kvEntries, limit, timeWindow);
}
@ -72,4 +78,8 @@ public interface ArgumentEntry {
return new PropagationArgumentEntry(entityIds);
}
static ArgumentEntry createAggArgument(Map<EntityId, ArgumentEntry> entityIdkvEntryMap) {
return new RelatedEntitiesArgumentEntry(entityIdkvEntryMap, false);
}
}

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

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

@ -23,6 +23,7 @@ import org.thingsboard.server.actors.TbActorRef;
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.utils.CalculatedFieldUtils;
import java.io.Closeable;
@ -79,7 +80,11 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
if (existingEntry == null || newEntry.isForceResetPrevious()) {
validateNewEntry(key, newEntry);
arguments.put(key, newEntry);
if (existingEntry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {
relatedEntitiesArgumentEntry.updateEntry(newEntry);
} else {
arguments.put(key, newEntry);
}
entryUpdated = true;
} else {
entryUpdated = existingEntry.updateEntry(newEntry);
@ -124,9 +129,11 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
}
@Override
public void close() {}
public void close() {
}
protected void validateNewEntry(String key, ArgumentEntry newEntry) {}
protected void validateNewEntry(String key, ArgumentEntry newEntry) {
}
protected ObjectNode toSimpleResult(boolean useLatestTs, ObjectNode valuesNode) {
if (!useLatestTs) {

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

@ -43,6 +43,8 @@ import org.thingsboard.server.common.data.cf.configuration.PropagationCalculated
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration;
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.geofencing.GeofencingCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -51,6 +53,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicKvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
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;
@ -65,8 +68,10 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Data
@Slf4j
@ -79,9 +84,10 @@ public class CalculatedFieldCtx implements Closeable {
private EntityId entityId;
private CalculatedFieldType cfType;
private final Map<String, Argument> arguments;
private final Map<ReferencedEntityKey, String> mainEntityArguments;
private final Map<EntityId, Map<ReferencedEntityKey, String>> linkedEntityArguments;
private final Map<ReferencedEntityKey, String> dynamicEntityArguments;
private final Map<ReferencedEntityKey, Set<String>> mainEntityArguments;
private final Map<EntityId, Map<ReferencedEntityKey, Set<String>>> linkedEntityArguments;
private final Map<ReferencedEntityKey, Set<String>> dynamicEntityArguments;
private final Map<ReferencedEntityKey, Set<String>> relatedEntityArguments;
private final List<String> argNames;
private Output output;
private String expression;
@ -105,6 +111,7 @@ public class CalculatedFieldCtx implements Closeable {
private boolean relationQueryDynamicArguments;
private List<String> mainEntityGeofencingArgumentNames;
private List<String> linkedEntityAndCurrentOwnerGeofencingArgumentNames;
private List<String> relatedEntityArgumentNames;
private long scheduledUpdateIntervalMillis;
@ -123,9 +130,11 @@ public class CalculatedFieldCtx implements Closeable {
this.mainEntityArguments = new HashMap<>();
this.linkedEntityArguments = new HashMap<>();
this.dynamicEntityArguments = new HashMap<>();
this.relatedEntityArguments = new HashMap<>();
this.argNames = new ArrayList<>();
this.mainEntityGeofencingArgumentNames = new ArrayList<>();
this.linkedEntityAndCurrentOwnerGeofencingArgumentNames = new ArrayList<>();
this.relatedEntityArgumentNames = new ArrayList<>();
this.output = calculatedField.getConfiguration().getOutput();
if (calculatedField.getConfiguration() instanceof ArgumentsBasedCalculatedFieldConfiguration argBasedConfig) {
this.arguments.putAll(argBasedConfig.getArguments());
@ -133,22 +142,30 @@ public class CalculatedFieldCtx implements Closeable {
var refId = entry.getValue().getRefEntityId();
var refKey = entry.getValue().getRefEntityKey();
if (refId == null) {
if (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cfType)) {
relatedEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey()));
continue;
}
if (entry.getValue().hasRelationQuerySource()) {
relationQueryDynamicArguments = true;
continue;
}
if (entry.getValue().hasOwnerSource()) {
dynamicEntityArguments.put(refKey, entry.getKey());
dynamicEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey()));
} else {
mainEntityArguments.put(refKey, entry.getKey());
mainEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey()));
}
} else if (refId.equals(calculatedField.getEntityId())) {
mainEntityArguments.put(refKey, entry.getKey());
mainEntityArguments.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey()));
} else {
linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>())
.compute(refKey, (key, existingNames) -> CollectionsUtil.addToSet(existingNames, entry.getKey()));
}
}
this.argNames.addAll(arguments.keySet());
this.relatedEntityArgumentNames = relatedEntityArguments.values().stream()
.flatMap(Set::stream)
.collect(Collectors.toList());
if (argBasedConfig instanceof ExpressionBasedCalculatedFieldConfiguration expressionBasedConfig) {
this.expression = expressionBasedConfig.getExpression();
this.useLatestTs = CalculatedFieldType.SIMPLE.equals(calculatedField.getType()) && ((SimpleCalculatedFieldConfiguration) argBasedConfig).isUseLatestTs();
@ -174,6 +191,9 @@ public class CalculatedFieldCtx implements Closeable {
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();
}
this.systemContext = systemContext;
this.tbelInvokeService = systemContext.getTbelInvokeService();
this.relationService = systemContext.getRelationService();
@ -211,6 +231,19 @@ public class CalculatedFieldCtx implements Closeable {
}
initialized = true;
}
case RELATED_ENTITIES_AGGREGATION -> {
RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) calculatedField.getConfiguration();
configuration.getMetrics().forEach((key, metric) -> {
if (metric.getInput() instanceof AggFunctionInput functionInput) {
initTbelExpression(functionInput.getFunction());
}
String filter = metric.getFilter();
if (filter != null && !filter.isEmpty()) {
initTbelExpression(filter);
}
});
initialized = true;
}
}
}
@ -233,15 +266,23 @@ public class CalculatedFieldCtx implements Closeable {
}
public ListenableFuture<Object> evaluateTbelExpression(String expression, CalculatedFieldState state) {
return evaluateTbelExpression(tbelExpressions.get(expression), state);
return evaluateTbelExpression(tbelExpressions.get(expression), state.getArguments(), state.getLatestTimestamp());
}
public ListenableFuture<Object> evaluateTbelExpression(CalculatedFieldScriptEngine expression, CalculatedFieldState state) {
return evaluateTbelExpression(expression, state.getArguments(), state.getLatestTimestamp());
}
public ListenableFuture<Object> evaluateTbelExpression(String expression, Map<String, ArgumentEntry> entries, long latestTimestamp) {
return evaluateTbelExpression(tbelExpressions.get(expression), entries, latestTimestamp);
}
public ListenableFuture<Object> evaluateTbelExpression(CalculatedFieldScriptEngine expression, Map<String, ArgumentEntry> entries, long latestTimestamp) {
Map<String, TbelCfArg> arguments = new LinkedHashMap<>();
List<Object> args = new ArrayList<>(argNames.size() + 1);
args.add(new Object()); // first element is a ctx, but we will set it later;
for (String argName : argNames) {
var arg = toTbelArgument(argName, state);
var arg = toTbelArgument(argName, entries);
arguments.put(argName, arg);
if (arg instanceof TbelCfSingleValueArg svArg) {
args.add(svArg.getValue());
@ -249,7 +290,7 @@ public class CalculatedFieldCtx implements Closeable {
args.add(arg);
}
}
args.set(0, new TbelCfCtx(arguments, state.getLatestTimestamp()));
args.set(0, new TbelCfCtx(arguments, latestTimestamp));
return expression.executeScriptAsync(args.toArray());
}
@ -259,8 +300,8 @@ public class CalculatedFieldCtx implements Closeable {
return systemContext.scheduleMsgWithDelay(actorCtx, new CalculatedFieldReevaluateMsg(tenantId, this), delayMs);
}
private TbelCfArg toTbelArgument(String key, CalculatedFieldState state) {
return state.getArguments().get(key).toTbelCfArg();
private TbelCfArg toTbelArgument(String key, Map<String, ArgumentEntry> arguments) {
return arguments.get(key).toTbelCfArg();
}
private void initTbelExpression(String expression) {
@ -346,7 +387,7 @@ public class CalculatedFieldCtx implements Closeable {
return matchesAttributes(dynamicEntityArguments, values, scope);
}
private boolean matchesAttributes(Map<ReferencedEntityKey, String> argMap, List<AttributeKvEntry> values, AttributeScope scope) {
private boolean matchesAttributes(Map<ReferencedEntityKey, Set<String>> argMap, List<AttributeKvEntry> values, AttributeScope scope) {
if (argMap.isEmpty() || values.isEmpty()) {
return false;
}
@ -360,7 +401,7 @@ public class CalculatedFieldCtx implements Closeable {
return false;
}
private boolean matchesTimeSeries(Map<ReferencedEntityKey, String> argMap, List<TsKvEntry> values) {
private boolean matchesTimeSeries(Map<ReferencedEntityKey, Set<String>> argMap, List<TsKvEntry> values) {
if (argMap.isEmpty() || values.isEmpty()) {
return false;
}
@ -397,7 +438,7 @@ public class CalculatedFieldCtx implements Closeable {
return matchesTimeSeriesKeys(dynamicEntityArguments, keys);
}
private boolean matchesAttributesKeys(Map<ReferencedEntityKey, String> argMap, List<String> keys, AttributeScope scope) {
private boolean matchesAttributesKeys(Map<ReferencedEntityKey, Set<String>> argMap, List<String> keys, AttributeScope scope) {
if (argMap.isEmpty() || keys.isEmpty()) {
return false;
}
@ -412,7 +453,7 @@ public class CalculatedFieldCtx implements Closeable {
return false;
}
private boolean matchesTimeSeriesKeys(Map<ReferencedEntityKey, String> argMap, List<String> keys) {
private boolean matchesTimeSeriesKeys(Map<ReferencedEntityKey, Set<String>> argMap, List<String> keys) {
if (argMap.isEmpty() || keys.isEmpty()) {
return false;
}
@ -443,6 +484,41 @@ public class CalculatedFieldCtx implements Closeable {
return map != null && matchesTimeSeriesKeys(map, keys);
}
public boolean relatedEntityMatches(List<TsKvEntry> values) {
return matchesTimeSeries(relatedEntityArguments, values);
}
public boolean relatedEntityMatches(List<AttributeKvEntry> values, AttributeScope scope) {
return matchesAttributes(relatedEntityArguments, values, scope);
}
public boolean matchesRelatedEntityKeys(List<String> keys, AttributeScope scope) {
return matchesAttributesKeys(relatedEntityArguments, keys, scope);
}
public boolean matchesRelatedEntityKeys(List<String> keys) {
return matchesTimeSeriesKeys(relatedEntityArguments, keys);
}
public boolean relatedEntityMatches(CalculatedFieldTelemetryMsgProto proto) {
if (!proto.getTsDataList().isEmpty()) {
List<TsKvEntry> updatedTelemetry = proto.getTsDataList().stream()
.map(ProtoUtils::fromProto)
.toList();
return relatedEntityMatches(updatedTelemetry);
} else if (!proto.getAttrDataList().isEmpty()) {
AttributeScope scope = AttributeScope.valueOf(proto.getScope().name());
List<AttributeKvEntry> updatedTelemetry = proto.getAttrDataList().stream()
.map(ProtoUtils::fromProto)
.toList();
return relatedEntityMatches(updatedTelemetry, scope);
} else if (!proto.getRemovedTsKeysList().isEmpty()) {
return matchesRelatedEntityKeys(proto.getRemovedTsKeysList());
} else {
return matchesRelatedEntityKeys(proto.getRemovedAttrKeysList(), AttributeScope.valueOf(proto.getScope().name()));
}
}
public boolean dynamicSourceMatches(CalculatedFieldTelemetryMsgProto proto) {
if (!proto.getTsDataList().isEmpty()) {
List<TsKvEntry> updatedTelemetry = proto.getTsDataList().stream()
@ -481,8 +557,8 @@ public class CalculatedFieldCtx implements Closeable {
}
}
public Map<ReferencedEntityKey, String> getLinkedAndDynamicArgs(EntityId entityId) {
var argNames = new HashMap<ReferencedEntityKey, String>();
public Map<ReferencedEntityKey, Set<String>> getLinkedAndDynamicArgs(EntityId entityId) {
var argNames = new HashMap<ReferencedEntityKey, Set<String>>();
var linkedArgNames = linkedEntityArguments.get(entityId);
if (linkedArgNames != null && !linkedArgNames.isEmpty()) {
argNames.putAll(linkedArgNames);
@ -504,6 +580,11 @@ public class CalculatedFieldCtx implements Closeable {
if (!Objects.equals(output, other.output)) {
return true;
}
if (calculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof SimpleCalculatedFieldConfiguration otherConfig
&& thisConfig.isUseLatestTs() != otherConfig.isUseLatestTs()) {
return true;
}
if (cfType == CalculatedFieldType.ALARM) {
if (!calculatedField.getName().equals(other.getCalculatedField().getName())) {
return true;
@ -516,7 +597,15 @@ public class CalculatedFieldCtx implements Closeable {
return true;
}
}
return scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis;
if (scheduledUpdateIntervalMillis != other.scheduledUpdateIntervalMillis) {
return true;
}
if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration thisConfig
&& other.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration otherConfig
&& (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) {
return true;
}
return false;
}
public boolean hasStateChanges(CalculatedFieldCtx other) {
@ -533,17 +622,31 @@ public class CalculatedFieldCtx implements Closeable {
return true;
}
}
return hasGeofencingZoneGroupConfigurationChanges(other);
if (hasGeofencingZoneGroupConfigurationChanges(other)) {
return true;
}
if (hasRelatedEntitiesAggregationConfigurationChanges(other)) {
return true;
}
return false;
}
private boolean hasGeofencingZoneGroupConfigurationChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration otherConfig) {
&& other.calculatedField.getConfiguration() instanceof GeofencingCalculatedFieldConfiguration otherConfig) {
return !thisConfig.getZoneGroups().equals(otherConfig.getZoneGroups());
}
return false;
}
private boolean hasRelatedEntitiesAggregationConfigurationChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration thisConfig
&& other.calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration otherConfig) {
return !thisConfig.getRelation().equals(otherConfig.getRelation());
}
return false;
}
private boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalMillis != -1;
}
@ -563,7 +666,7 @@ public class CalculatedFieldCtx implements Closeable {
yield true;
}
yield geofencingState.getLastDynamicArgumentsRefreshTs() <
System.currentTimeMillis() - scheduledUpdateIntervalMillis;
System.currentTimeMillis() - scheduledUpdateIntervalMillis;
}
default -> false;
};
@ -594,10 +697,10 @@ public class CalculatedFieldCtx implements Closeable {
@Override
public String toString() {
return "CalculatedFieldCtx{" +
"cfId=" + cfId +
", cfType=" + cfType +
", entityId=" + entityId +
'}';
"cfId=" + cfId +
", cfType=" + cfType +
", entityId=" + entityId +
'}';
}
}

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

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.EntityId;
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.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
@ -47,7 +48,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg
@Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"),
@Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION")
@Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION"),
@Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION")
})
public interface CalculatedFieldState extends Closeable {
@ -68,7 +70,7 @@ public interface CalculatedFieldState extends Closeable {
void reset();
ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx);
ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception;
@JsonIgnore
boolean isReady();

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

@ -52,7 +52,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
double expressionResult = ctx.evaluateSimpleExpression(expression.get(), this);
Output output = ctx.getOutput();
Object result = formatResult(expressionResult, output.getDecimalsByDefault());
Object result = TbUtils.roundResult(expressionResult, output.getDecimalsByDefault());
JsonNode outputResult = createResultJson(ctx.isUseLatestTs(), output.getName(), result);
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder()
@ -62,16 +62,6 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
.build());
}
private Object formatResult(double expressionResult, Integer decimals) {
if (decimals == null) {
return expressionResult;
}
if (decimals.equals(0)) {
return TbUtils.toInt(expressionResult);
}
return TbUtils.toFixed(expressionResult, decimals);
}
private JsonNode createResultJson(boolean useLatestTs, String outputName, Object result) {
ObjectNode valuesNode = JacksonUtil.newObjectNode();
if (result instanceof Double doubleValue) {

47
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java

@ -20,9 +20,11 @@ import com.fasterxml.jackson.core.type.TypeReference;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.lang.Nullable;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
@ -37,14 +39,36 @@ import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto;
@AllArgsConstructor
public class SingleValueArgumentEntry implements ArgumentEntry {
private long ts;
private BasicKvEntry kvEntryValue;
private Long version;
@Nullable
protected EntityId entityId;
private boolean forceResetPrevious;
protected long ts;
protected BasicKvEntry kvEntryValue;
protected Long version;
protected boolean forceResetPrevious;
public static final Long DEFAULT_VERSION = -1L;
public SingleValueArgumentEntry(EntityId entityId, ArgumentEntry entry) {
this(entry);
this.entityId = entityId;
}
public SingleValueArgumentEntry(ArgumentEntry entry) {
if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
this.ts = singleValueArgumentEntry.ts;
this.kvEntryValue = singleValueArgumentEntry.kvEntryValue;
this.version = singleValueArgumentEntry.version;
this.forceResetPrevious = singleValueArgumentEntry.forceResetPrevious;
}
}
public SingleValueArgumentEntry(EntityId entityId, TsKvProto entry) {
this(entry);
this.entityId = entityId;
}
public SingleValueArgumentEntry(TsKvProto entry) {
this.ts = entry.getTs();
if (entry.hasVersion()) {
@ -53,6 +77,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry {
this.kvEntryValue = ProtoUtils.fromProto(entry.getKv());
}
public SingleValueArgumentEntry(EntityId entityId, AttributeValueProto entry) {
this(entry);
this.entityId = entityId;
}
public SingleValueArgumentEntry(AttributeValueProto entry) {
this.ts = entry.getLastUpdateTs();
if (entry.hasVersion()) {
@ -61,6 +90,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry {
this.kvEntryValue = ProtoUtils.basicKvEntryFromProto(entry);
}
public SingleValueArgumentEntry(EntityId entityId, KvEntry entry) {
this(entry);
this.entityId = entityId;
}
public SingleValueArgumentEntry(KvEntry entry) {
if (entry instanceof TsKvEntry tsKvEntry) {
this.ts = tsKvEntry.getTs();
@ -72,6 +106,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry {
this.kvEntryValue = ProtoUtils.basicKvEntryFromKvEntry(entry);
}
public SingleValueArgumentEntry(EntityId entityId, long ts, BasicKvEntry kvEntryValue, Long version) {
this(ts, kvEntryValue, version);
this.entityId = entityId;
}
public SingleValueArgumentEntry(long ts, BasicKvEntry kvEntryValue, Long version) {
this.ts = ts;
this.kvEntryValue = kvEntryValue;

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

@ -0,0 +1,188 @@
/**
* 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;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.JacksonUtil;
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.AggFunctionInput;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggInput;
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.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
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 org.thingsboard.server.service.cf.ctx.state.aggregation.function.AggEntry;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import static java.util.concurrent.TimeUnit.SECONDS;
@Slf4j
@Getter
public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculatedFieldState {
@Setter
private long lastArgsRefreshTs = -1;
@Setter
private long lastMetricsEvalTs = -1;
private long deduplicationIntervalMs = -1;
private Map<String, AggMetric> metrics;
public RelatedEntitiesAggregationCalculatedFieldState(EntityId entityId) {
super(entityId);
}
@Override
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) {
super.setCtx(ctx, actorCtx);
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration();
metrics = configuration.getMetrics();
deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec());
}
public void scheduleReevaluation() {
ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx);
}
@Override
public void reset() { // must reset everything dependent on arguments
super.reset();
lastArgsRefreshTs = -1;
lastMetricsEvalTs = -1;
metrics = null;
}
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.RELATED_ENTITIES_AGGREGATION;
}
@Override
public Map<String, ArgumentEntry> update(Map<String, ArgumentEntry> argumentValues, CalculatedFieldCtx ctx) {
lastArgsRefreshTs = System.currentTimeMillis();
return super.update(argumentValues, ctx);
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception {
boolean cfUpdated = updatedArgs != null && updatedArgs.isEmpty();
if (shouldRecalculate() || cfUpdated) {
Output output = ctx.getOutput();
ObjectNode aggResult = aggregateMetrics(output);
lastMetricsEvalTs = System.currentTimeMillis();
ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx);
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder()
.type(output.getType())
.scope(output.getScope())
.result(toSimpleResult(ctx.isUseLatestTs(), aggResult))
.build());
} else {
return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY);
}
}
public Map<String, ArgumentEntry> updateEntityData(Map<String, ArgumentEntry> fetchedArgs) {
lastMetricsEvalTs = -1;
return update(fetchedArgs, ctx);
}
public void cleanupEntityData(EntityId relatedEntityId) {
arguments.values().forEach(argEntry -> {
RelatedEntitiesArgumentEntry aggEntry = (RelatedEntitiesArgumentEntry) argEntry;
aggEntry.getEntityInputs().remove(relatedEntityId);
});
lastMetricsEvalTs = -1;
lastArgsRefreshTs = System.currentTimeMillis();
}
private boolean shouldRecalculate() {
boolean intervalPassed = lastMetricsEvalTs <= System.currentTimeMillis() - deduplicationIntervalMs;
boolean argsUpdatedDuringInterval = lastArgsRefreshTs > lastMetricsEvalTs;
return intervalPassed && argsUpdatedDuringInterval;
}
private Map<EntityId, Map<String, ArgumentEntry>> prepareInputs() {
Map<EntityId, Map<String, ArgumentEntry>> inputs = new HashMap<>();
for (Map.Entry<String, ArgumentEntry> argEntry : arguments.entrySet()) {
String key = argEntry.getKey();
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = (RelatedEntitiesArgumentEntry) argEntry.getValue();
relatedEntitiesArgumentEntry.getEntityInputs().forEach((entityId, argumentEntry) -> {
inputs.computeIfAbsent(entityId, k -> new HashMap<>()).put(key, argumentEntry);
});
}
return inputs;
}
private ObjectNode aggregateMetrics(Output output) throws Exception {
ObjectNode aggResult = JacksonUtil.newObjectNode();
Map<EntityId, Map<String, ArgumentEntry>> inputs = prepareInputs();
for (Entry<String, AggMetric> entry : metrics.entrySet()) {
String metricKey = entry.getKey();
AggMetric metric = entry.getValue();
AggEntry aggMetricEntry = AggEntry.createAggFunction(metric.getFunction());
aggregateMetric(metric, aggMetricEntry, inputs);
aggMetricEntry.result(output.getDecimalsByDefault()).ifPresent(result -> {
aggResult.set(metricKey, JacksonUtil.valueToTree(result));
});
}
return aggResult;
}
private void aggregateMetric(AggMetric metric, AggEntry aggEntry, Map<EntityId, Map<String, ArgumentEntry>> inputs) throws Exception {
for (Map<String, ArgumentEntry> entityInputs : inputs.values()) {
if (applyAggregation(metric.getFilter(), entityInputs)) {
Object arg = resolveAggregationInput(metric.getInput(), entityInputs);
if (arg != null) {
aggEntry.update(arg);
}
}
}
}
private boolean applyAggregation(String filter, Map<String, ArgumentEntry> entityInputs) throws Exception {
if (filter == null || filter.isEmpty()) {
return true;
} else {
Object filterResult = ctx.evaluateTbelExpression(filter, entityInputs, getLatestTimestamp()).get();
return filterResult instanceof Boolean booleanResult && booleanResult;
}
}
private Object resolveAggregationInput(AggInput aggInput, Map<String, ArgumentEntry> entityInputs) throws Exception {
if (aggInput instanceof AggFunctionInput functionInput) {
return ctx.evaluateTbelExpression(functionInput.getFunction(), entityInputs, getLatestTimestamp()).get();
} else {
String inputKey = ((AggKeyInput) aggInput).getKey();
return entityInputs.get(inputKey).getValue();
}
}
}

86
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesArgumentEntry.java

@ -0,0 +1,86 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation;
import lombok.AllArgsConstructor;
import lombok.Data;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfRelatedEntitiesArgumentValue;
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg;
import org.thingsboard.server.common.data.id.EntityId;
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;
import java.util.stream.Collectors;
@Data
@AllArgsConstructor
public class RelatedEntitiesArgumentEntry implements ArgumentEntry {
private final Map<EntityId, ArgumentEntry> entityInputs;
private boolean forceResetPrevious;
@Override
public ArgumentEntryType getType() {
return ArgumentEntryType.RELATED_ENTITIES;
}
@Override
public Object getValue() {
return entityInputs;
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) {
entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs);
return true;
} else if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
if (entry.isForceResetPrevious()) {
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry);
return true;
}
ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId());
if (argumentEntry != null) {
argumentEntry.updateEntry(singleValueArgumentEntry);
} else {
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry);
}
return true;
} else {
throw new IllegalArgumentException("Unsupported argument entry type for aggregation argument entry: " + entry.getType());
}
}
@Override
public boolean isEmpty() {
return entityInputs.isEmpty();
}
@Override
public TbelCfArg toTbelCfArg() {
var inputs = entityInputs.entrySet().stream()
.collect(Collectors.toMap(
e -> e.getKey().getId(),
e -> (TbelCfSingleValueArg) e.getValue().toTbelCfArg()
));
return new TbelCfRelatedEntitiesArgumentValue(inputs);
}
}

58
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AggEntry.java

@ -0,0 +1,58 @@
/**
* 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.function;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import java.util.Optional;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = AvgAggEntry.class, name = "AVG"),
@JsonSubTypes.Type(value = CountAggEntry.class, name = "COUNT"),
@JsonSubTypes.Type(value = CountUniqueAggEntry.class, name = "COUNT_UNIQUE"),
@JsonSubTypes.Type(value = MaxAggEntry.class, name = "MAX"),
@JsonSubTypes.Type(value = MinAggEntry.class, name = "MIN"),
@JsonSubTypes.Type(value = SumAggEntry.class, name = "SUM")
})
public interface AggEntry {
@JsonIgnore
AggFunction getType();
void update(Object value);
Optional<Object> result(Integer precision);
static AggEntry createAggFunction(AggFunction function) {
return switch (function) {
case MIN -> new MinAggEntry();
case MAX -> new MaxAggEntry();
case SUM -> new SumAggEntry();
case AVG -> new AvgAggEntry();
case COUNT -> new CountAggEntry();
case COUNT_UNIQUE -> new CountUniqueAggEntry();
};
}
}

47
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/AvgAggEntry.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.service.cf.ctx.state.aggregation.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import java.math.BigDecimal;
import java.math.RoundingMode;
public class AvgAggEntry extends BaseAggEntry {
private BigDecimal sum = BigDecimal.ZERO;
private long count = 0L;
@Override
protected void doUpdate(double value) {
if (value != 0.0) {
sum = sum.add(BigDecimal.valueOf(value));
}
this.count++;
}
@Override
protected Object prepareResult(Integer precision) {
double result = sum.divide(BigDecimal.valueOf(count), RoundingMode.HALF_UP).doubleValue();
return TbUtils.roundResult(result, precision);
}
@Override
public AggFunction getType() {
return AggFunction.AVG;
}
}

55
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/BaseAggEntry.java

@ -0,0 +1,55 @@
/**
* 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.function;
import java.util.Optional;
public abstract class BaseAggEntry implements AggEntry {
private boolean hasResult = false;
@Override
public void update(Object value) {
doUpdate(extractDoubleValue(value));
hasResult = true;
}
@Override
public Optional<Object> result(Integer precision) {
if (hasResult) {
hasResult = false;
return Optional.of(prepareResult(precision));
} else {
return Optional.empty();
}
}
protected abstract void doUpdate(double value);
protected abstract Object prepareResult(Integer precision);
protected double extractDoubleValue(Object value) {
try {
if (value instanceof Number number) {
return number.doubleValue();
}
return Double.parseDouble(value.toString());
} catch (Exception e) {
throw new NumberFormatException("Cannot parse value " + value.toString());
}
}
}

41
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountAggEntry.java

@ -0,0 +1,41 @@
/**
* 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.function;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import java.util.Optional;
public class CountAggEntry implements AggEntry {
private long count = 0L;
@Override
public void update(Object value) {
count++;
}
@Override
public Optional<Object> result(Integer precision) {
return Optional.of(count);
}
@Override
public AggFunction getType() {
return AggFunction.COUNT;
}
}

43
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/CountUniqueAggEntry.java

@ -0,0 +1,43 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.function;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import java.util.Optional;
import java.util.Set;
public class CountUniqueAggEntry implements AggEntry {
private Set<String> items;
@Override
public void update(Object value) {
if (value != null) {
items.add(String.valueOf(value));
}
}
@Override
public Optional<Object> result(Integer precision) {
return Optional.of(items.size());
}
@Override
public AggFunction getType() {
return AggFunction.COUNT_UNIQUE;
}
}

41
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MaxAggEntry.java

@ -0,0 +1,41 @@
/**
* 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.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
public class MaxAggEntry extends BaseAggEntry {
private double max = Double.MIN_VALUE;
@Override
protected void doUpdate(double value) {
if (value > max) {
max = value;
}
}
@Override
protected Object prepareResult(Integer precision) {
return TbUtils.roundResult(max, precision);
}
@Override
public AggFunction getType() {
return AggFunction.MAX;
}
}

41
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/MinAggEntry.java

@ -0,0 +1,41 @@
/**
* 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.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
public class MinAggEntry extends BaseAggEntry {
private double min = Double.MAX_VALUE;
@Override
protected void doUpdate(double value) {
if (value < min) {
min = value;
}
}
@Override
protected Object prepareResult(Integer precision) {
return TbUtils.roundResult(min, precision);
}
@Override
public AggFunction getType() {
return AggFunction.MIN;
}
}

43
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/function/SumAggEntry.java

@ -0,0 +1,43 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state.aggregation.function;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction;
import java.math.BigDecimal;
public class SumAggEntry extends BaseAggEntry {
private BigDecimal sum = BigDecimal.ZERO;
@Override
protected void doUpdate(double value) {
if (value != 0.0) {
sum = sum.add(BigDecimal.valueOf(value));
}
}
@Override
protected Object prepareResult(Integer precision) {
return TbUtils.roundResult(sum.doubleValue(), precision);
}
@Override
public AggFunction getType() {
return AggFunction.SUM;
}
}

2
application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java

@ -259,7 +259,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService<Device> {
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration();
transportConfiguration.setBootstrap(Collections.emptyList());
transportConfiguration.setClientLwM2mSettings(new OtherConfiguration(false,1, 1, 1, PowerMode.DRX, null, null, null, null, null, V1_0.toString()));
transportConfiguration.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap(), SINGLE));
transportConfiguration.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap(), false, SINGLE));
DeviceProfileData deviceProfileData = new DeviceProfileData();
DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration();

11
application/src/main/java/org/thingsboard/server/service/device/DeviceProvisionServiceImpl.java

@ -186,9 +186,14 @@ public class DeviceProvisionServiceImpl implements DeviceProvisionService {
try {
Optional<AttributeKvEntry> provisionState = attributesService.find(device.getTenantId(), device.getId(),
AttributeScope.SERVER_SCOPE, DEVICE_PROVISION_STATE).get();
if (provisionState != null && provisionState.isPresent() && !provisionState.get().getValueAsString().equals(PROVISIONED_STATE)) {
notify(device, provisionRequest, TbMsgType.PROVISION_FAILURE, false);
throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name());
if (provisionState != null && provisionState.isPresent()) {
if (provisionState.get().getValueAsString().equals(PROVISIONED_STATE)) {
notify(device, provisionRequest, TbMsgType.PROVISION_FAILURE, false);
throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name());
} else {
log.error("[{}][{}] Unknown provision state: {}!", device.getName(), DEVICE_PROVISION_STATE, provisionState.get().getValueAsString());
throw new ProvisionFailedException(ProvisionResponseStatus.FAILURE.name());
}
} else {
saveProvisionStateAttribute(device).get();
notify(device, provisionRequest, TbMsgType.PROVISION_SUCCESS, true);

11
application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java

@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ObjectType;
import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.TbResourceInfo;
import org.thingsboard.server.common.data.Tenant;
@ -61,6 +62,7 @@ import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.dao.edge.EdgeSynchronizationManager;
import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.RelationActionEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.gen.transport.TransportProtos.EntityActionEventProto;
@ -270,6 +272,15 @@ public class EntityStateSourcingListener {
}
}
@TransactionalEventListener(fallbackExecution = true)
public void handleEvent(RelationActionEvent relationEvent) {
if (relationEvent.getActionType() == ActionType.RELATION_ADD_OR_UPDATE) {
tbClusterService.onRelationUpdated(relationEvent.getTenantId(), relationEvent.getRelation(), TbQueueCallback.EMPTY);
} else if (relationEvent.getActionType() == ActionType.RELATION_DELETED) {
tbClusterService.onRelationDeleted(relationEvent.getTenantId(), relationEvent.getRelation(), TbQueueCallback.EMPTY);
}
}
private void onTenantUpdate(Tenant tenant, ComponentLifecycleEvent lifecycleEvent) {
tbClusterService.onTenantChange(tenant, null);
tbClusterService.broadcastEntityStateChangeEvent(tenant.getId(), tenant.getId(), lifecycleEvent);

2
application/src/main/java/org/thingsboard/server/service/ota/DefaultOtaPackageStateService.java

@ -328,7 +328,7 @@ public class DefaultOtaPackageStateService implements OtaPackageStateService {
attributes.add(new BaseAttributeKvEntry(ts, new LongDataEntry(getAttributeKey(otaPackageType, SIZE), otaPackage.getDataSize())));
}
if (otaPackage.getChecksumAlgorithm() != null) {
if (otaPackage.getChecksumAlgorithm() == null) {
attrToRemove.add(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM));
} else {
attributes.add(new BaseAttributeKvEntry(ts, new StringDataEntry(getAttributeKey(otaPackageType, CHECKSUM_ALGORITHM), otaPackage.getChecksumAlgorithm().name())));

23
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java

@ -56,6 +56,7 @@ import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.queue.Queue;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
@ -723,6 +724,28 @@ public class DefaultTbClusterService implements TbClusterService {
broadcastEntityStateChangeEvent(calculatedField.getTenantId(), calculatedField.getId(), ComponentLifecycleEvent.DELETED);
}
@Override
public void onRelationUpdated(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback) {
ComponentLifecycleMsg msg = ComponentLifecycleMsg.builder()
.tenantId(tenantId)
.entityId(entityRelation.getFrom())
.event(ComponentLifecycleEvent.RELATION_UPDATED)
.info(JacksonUtil.valueToTree(entityRelation))
.build();
broadcast(msg);
}
@Override
public void onRelationDeleted(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback) {
ComponentLifecycleMsg msg = ComponentLifecycleMsg.builder()
.tenantId(tenantId)
.entityId(entityRelation.getFrom())
.event(ComponentLifecycleEvent.RELATION_DELETED)
.info(JacksonUtil.valueToTree(entityRelation))
.build();
broadcast(msg);
}
@Override
public void sendNotificationMsgToEdge(TenantId tenantId, EdgeId edgeId, EntityId entityId, String body, EdgeEventType type, EdgeEventActionType action, EdgeId originatorEdgeId) {
if (!edgesEnabled) {

13
application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java

@ -18,8 +18,10 @@ package org.thingsboard.server.service.security.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@ -32,6 +34,10 @@ public class AuthExceptionHandler extends OncePerRequestFilter {
private final ThingsboardErrorResponseHandler errorResponseHandler;
@Value("${server.log_controller_error_stack_trace}")
@Getter
private boolean logControllerErrorStackTrace;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
try {
@ -39,8 +45,15 @@ public class AuthExceptionHandler extends OncePerRequestFilter {
} catch (AuthenticationException e) {
throw e;
} catch (Exception e) {
log(e);
errorResponseHandler.handle(e, response);
}
}
private void log(Exception e) {
if (logControllerErrorStackTrace) {
log.error("Auth error", e);
}
}
}

24
application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.auth;
import org.thingsboard.server.service.security.model.SecurityUser;
public class MfaConfigurationToken extends AbstractJwtAuthenticationToken {
public MfaConfigurationToken(SecurityUser securityUser) {
super(securityUser);
}
}

22
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java

@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
@ -61,12 +62,25 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
private static final ThingsboardException TOO_MANY_REQUESTS_ERROR = new ThingsboardException("Too many requests", ThingsboardErrorCode.TOO_MANY_REQUESTS);
@Override
public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) {
return configManager.getAccountTwoFaSettings(tenantId, userId)
public boolean isTwoFaEnabled(TenantId tenantId, User user) {
return configManager.getAccountTwoFaSettings(tenantId, user)
.map(settings -> !settings.getConfigs().isEmpty())
.orElse(false);
}
@Override
public boolean isEnforceTwoFaEnabled(TenantId tenantId, User user) {
SystemLevelUsersFilter enforcedUsersFilter = configManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true)
.filter(PlatformTwoFaSettings::isEnforceTwoFa)
.map(PlatformTwoFaSettings::getEnforcedUsersFilter)
.orElse(null);
if (enforcedUsersFilter == null) {
return false;
}
return userService.matchesFilter(tenantId, enforcedUsersFilter, user);
}
@Override
public void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException {
getTwoFaProvider(providerType).check(tenantId);
@ -75,7 +89,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
@Override
public void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception {
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType)
.orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR);
prepareVerificationCode(user, accountConfig, checkLimits);
}
@ -104,7 +118,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
@Override
public boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException {
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType)
.orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR);
return checkVerificationCode(user, verificationCode, accountConfig, checkLimits);
}

6
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java

@ -18,17 +18,17 @@ package org.thingsboard.server.service.security.auth.mfa;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.service.security.model.SecurityUser;
public interface TwoFactorAuthService {
boolean isTwoFaEnabled(TenantId tenantId, UserId userId);
boolean isTwoFaEnabled(TenantId tenantId, User user);
void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException;
boolean isEnforceTwoFaEnabled(TenantId tenantId, User user);
void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException;
void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception;

49
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java

@ -21,15 +21,16 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.UserAuthSettings;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.ConstraintValidator;
import org.thingsboard.server.dao.settings.AdminSettingsDao;
import org.thingsboard.server.dao.settings.AdminSettingsService;
@ -55,9 +56,9 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
@Override
public Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, UserId userId) {
public Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, User user) {
PlatformTwoFaSettings platformTwoFaSettings = getPlatformTwoFaSettings(tenantId, true).orElse(null);
return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId))
return Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId()))
.map(userAuthSettings -> {
AccountTwoFaSettings twoFaSettings = userAuthSettings.getTwoFaSettings();
if (twoFaSettings == null) return null;
@ -79,17 +80,22 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
}
if (updateNeeded) {
twoFaSettings = saveAccountTwoFaSettings(tenantId, userId, twoFaSettings);
twoFaSettings = saveAccountTwoFaSettings(tenantId, user, twoFaSettings);
}
return twoFaSettings;
});
}
protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) {
UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId))
protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, User user, AccountTwoFaSettings settings) {
if (settings.getConfigs().isEmpty()) {
if (twoFactorAuthService.isEnforceTwoFaEnabled(tenantId, user)) {
throw new DataValidationException("At least one 2FA provider is required");
}
}
UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId()))
.orElseGet(() -> {
UserAuthSettings newUserAuthSettings = new UserAuthSettings();
newUserAuthSettings.setUserId(userId);
newUserAuthSettings.setUserId(user.getId());
return newUserAuthSettings;
});
userAuthSettings.setTwoFaSettings(settings);
@ -101,18 +107,18 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
@Override
public Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) {
return getAccountTwoFaSettings(tenantId, userId)
public Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) {
return getAccountTwoFaSettings(tenantId, user)
.map(AccountTwoFaSettings::getConfigs)
.flatMap(configs -> Optional.ofNullable(configs.get(providerType)));
}
@Override
public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig) {
public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig) {
getTwoFaProviderConfig(tenantId, accountConfig.getProviderType())
.orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured"));
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> {
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user).orElseGet(() -> {
AccountTwoFaSettings newSettings = new AccountTwoFaSettings();
newSettings.setConfigs(new LinkedHashMap<>());
return newSettings;
@ -128,12 +134,12 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
if (configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) {
configs.values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true));
}
return saveAccountTwoFaSettings(tenantId, userId, settings);
return saveAccountTwoFaSettings(tenantId, user, settings);
}
@Override
public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) {
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId)
public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) {
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user)
.orElseThrow(() -> new IllegalArgumentException("2FA not configured"));
settings.getConfigs().remove(providerType);
if (settings.getConfigs().size() == 1) {
@ -145,7 +151,7 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
.min(Comparator.comparing(TwoFaAccountConfig::getProviderType))
.ifPresent(config -> config.setUseByDefault(true));
}
return saveAccountTwoFaSettings(tenantId, userId, settings);
return saveAccountTwoFaSettings(tenantId, user, settings);
}
@ -166,6 +172,19 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) {
twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType());
}
if (tenantId.isSysTenantId()) {
if (twoFactorAuthSettings.isEnforceTwoFa()) {
if (twoFactorAuthSettings.getProviders().isEmpty()) {
throw new DataValidationException("At least one 2FA provider is required if enforcing is enabled");
}
if (twoFactorAuthSettings.getEnforcedUsersFilter() == null) {
throw new DataValidationException("Users filter to enforce 2FA for is required");
}
}
} else {
twoFactorAuthSettings.setEnforceTwoFa(false);
twoFactorAuthSettings.setEnforcedUsersFilter(null);
}
AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY))
.orElseGet(() -> {

10
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java

@ -15,9 +15,9 @@
*/
package org.thingsboard.server.service.security.auth.mfa.config;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
@ -27,14 +27,14 @@ import java.util.Optional;
public interface TwoFaConfigManager {
Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, UserId userId);
Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, User user);
Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType);
Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType);
AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig);
AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig);
AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType);
AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType);
Optional<PlatformTwoFaSettings> getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault);

2
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java

@ -58,7 +58,7 @@ public class BackupCodeTwoFaProvider implements TwoFaProvider<BackupCodeTwoFaPro
public boolean checkVerificationCode(SecurityUser user, String code, BackupCodeTwoFaProviderConfig providerConfig, BackupCodeTwoFaAccountConfig accountConfig) {
if (CollectionsUtil.contains(accountConfig.getCodes(), code)) {
accountConfig.getCodes().remove(code);
twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig);
return true;
} else {
return false;

8
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java

@ -44,6 +44,7 @@ import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.MfaAuthenticationToken;
import org.thingsboard.server.service.security.auth.MfaConfigurationToken;
import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService;
import org.thingsboard.server.service.security.exception.UserPasswordNotValidException;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -82,11 +83,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
Assert.notNull(authentication, "No authentication data provided");
Object principal = authentication.getPrincipal();
if (!(principal instanceof UserPrincipal)) {
if (!(principal instanceof UserPrincipal userPrincipal)) {
throw new BadCredentialsException("Authentication Failed. Bad user principal.");
}
UserPrincipal userPrincipal = (UserPrincipal) principal;
SecurityUser securityUser;
if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) {
String username = userPrincipal.getValue();
@ -103,8 +103,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
}
securityUser = authenticateByUsernameAndPassword(authentication, userPrincipal, username, password);
if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser.getId())) {
if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser)) {
return new MfaAuthenticationToken(securityUser);
} else if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), securityUser)) {
return new MfaConfigurationToken(securityUser);
} else {
systemSecurityService.logLoginAction(securityUser, authentication.getDetails(), ActionType.LOGIN, null);
}

32
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java

@ -15,11 +15,11 @@
*/
package org.thingsboard.server.service.security.auth.rest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
@ -30,6 +30,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.service.security.auth.MfaAuthenticationToken;
import org.thingsboard.server.service.security.auth.MfaConfigurationToken;
import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
@ -38,7 +39,7 @@ import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Component(value = "defaultAuthenticationSuccessHandler")
@Slf4j @Component(value = "defaultAuthenticationSuccessHandler")
@RequiredArgsConstructor
public class RestAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenFactory tokenFactory;
@ -46,18 +47,14 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Authentication authentication) throws IOException {
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
JwtPair tokenPair = new JwtPair();
JwtPair tokenPair;
if (authentication instanceof MfaAuthenticationToken) {
int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true)
.flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30));
tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken());
tokenPair.setRefreshToken(null);
tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN);
tokenPair = createMfaTokenPair(securityUser, Authority.PRE_VERIFICATION_TOKEN);
} else if (authentication instanceof MfaConfigurationToken) {
tokenPair = createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN);
} else {
tokenPair = tokenFactory.createTokenPair(securityUser);
}
@ -69,6 +66,19 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
clearAuthenticationAttributes(request);
}
public JwtPair createMfaTokenPair(SecurityUser securityUser, Authority scope) {
log.debug("[{}][{}] Creating {} token", securityUser.getTenantId(), securityUser.getId(), scope);
JwtPair tokenPair = new JwtPair();
int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true)
.flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30));
tokenPair.setToken(tokenFactory.createMfaToken(securityUser, scope, preVerificationTokenLifetime).getToken());
tokenPair.setRefreshToken(null);
tokenPair.setScope(scope);
return tokenPair;
}
/**
* Removes temporary authentication-related data which may have been stored
* in the session during the authentication process..

22
application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java

@ -115,13 +115,16 @@ public class JwtTokenFactory {
throw new IllegalArgumentException("JWT Token doesn't have any scopes");
}
Authority authority = Authority.parse(scopes.get(0));
SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class))));
securityUser.setEmail(subject);
securityUser.setAuthority(Authority.parse(scopes.get(0)));
securityUser.setAuthority(authority);
String tenantId = claims.get(TENANT_ID, String.class);
if (tenantId != null) {
securityUser.setTenantId(TenantId.fromUUID(UUID.fromString(tenantId)));
} else if (securityUser.getAuthority() == Authority.SYS_ADMIN) {
} else if (authority == Authority.SYS_ADMIN) {
securityUser.setTenantId(TenantId.SYS_TENANT_ID);
}
String customerId = claims.get(CUSTOMER_ID, String.class);
@ -132,18 +135,15 @@ public class JwtTokenFactory {
securityUser.setSessionId(claims.get(SESSION_ID, String.class));
}
UserPrincipal principal;
if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_TOKEN) {
boolean isPublic = false;
if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.MFA_CONFIGURATION_TOKEN) {
securityUser.setFirstName(claims.get(FIRST_NAME, String.class));
securityUser.setLastName(claims.get(LAST_NAME, String.class));
securityUser.setEnabled(claims.get(ENABLED, Boolean.class));
boolean isPublic = claims.get(IS_PUBLIC, Boolean.class);
principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject);
} else {
principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, subject);
isPublic = claims.get(IS_PUBLIC, Boolean.class);
}
UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject);
securityUser.setUserPrincipal(principal);
return securityUser;
}
@ -179,8 +179,8 @@ public class JwtTokenFactory {
return securityUser;
}
public JwtToken createPreVerificationToken(SecurityUser user, Integer expirationTime) {
JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime)
public JwtToken createMfaToken(SecurityUser user, Authority scope, Integer expirationTime) {
JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(scope.name()), expirationTime)
.claim(TENANT_ID, user.getTenantId().toString());
if (user.getCustomerId() != null) {
jwtBuilder.claim(CUSTOMER_ID, user.getCustomerId().toString());

2
application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java

@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@Component(value = "customerUserPermissions")
@Component
public class CustomerUserPermissions extends AbstractPermissions {
public CustomerUserPermissions() {

13
application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java

@ -16,7 +16,6 @@
package org.thingsboard.server.service.security.permission;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
@ -33,18 +32,18 @@ import java.util.Optional;
@Slf4j
public class DefaultAccessControlService implements AccessControlService {
private static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!";
private final Map<Authority, Permissions> authorityPermissions = new HashMap<>();
public DefaultAccessControlService(
@Qualifier("sysAdminPermissions") Permissions sysAdminPermissions,
@Qualifier("tenantAdminPermissions") Permissions tenantAdminPermissions,
@Qualifier("customerUserPermissions") Permissions customerUserPermissions) {
public DefaultAccessControlService(SysAdminPermissions sysAdminPermissions,
TenantAdminPermissions tenantAdminPermissions,
CustomerUserPermissions customerUserPermissions,
MfaConfigurationPermissions mfaConfigurationPermissions) {
authorityPermissions.put(Authority.SYS_ADMIN, sysAdminPermissions);
authorityPermissions.put(Authority.TENANT_ADMIN, tenantAdminPermissions);
authorityPermissions.put(Authority.CUSTOMER_USER, customerUserPermissions);
authorityPermissions.put(Authority.MFA_CONFIGURATION_TOKEN, mfaConfigurationPermissions);
}
@Override
@ -58,7 +57,7 @@ public class DefaultAccessControlService implements AccessControlService {
@Override
@SuppressWarnings("unchecked")
public <I extends EntityId, T extends HasTenantId> void checkPermission(SecurityUser user, Resource resource,
Operation operation, I entityId, T entity) throws ThingsboardException {
Operation operation, I entityId, T entity) throws ThingsboardException {
PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource);
if (!permissionChecker.hasPermission(user, operation, entityId, entity)) {
permissionDenied();

28
application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java

@ -0,0 +1,28 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.security.permission;
import org.springframework.stereotype.Component;
@Component
public class MfaConfigurationPermissions extends AbstractPermissions {
public MfaConfigurationPermissions() {
super();
// for compatibility with PE
}
}

2
application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java

@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@Component(value = "sysAdminPermissions")
@Component
public class SysAdminPermissions extends AbstractPermissions {
public SysAdminPermissions() {

4
application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java

@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@Component(value = "tenantAdminPermissions")
@Component
public class TenantAdminPermissions extends AbstractPermissions {
public TenantAdminPermissions() {
@ -73,7 +73,7 @@ public class TenantAdminPermissions extends AbstractPermissions {
};
private static final PermissionChecker tenantPermissionChecker =
new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY) {
new PermissionChecker.GenericPermissionChecker(Operation.READ, Operation.READ_ATTRIBUTES, Operation.READ_TELEMETRY, Operation.DELETE) {
@Override
@SuppressWarnings("unchecked")

2
application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java

@ -193,7 +193,7 @@ public abstract class AbstractBulkImportService<E extends HasId<? extends Entity
});
return Map.entry(kvType, kvs);
})
.filter(kvsEntry -> kvsEntry.getValue().entrySet().size() > 0)
.filter(kvsEntry -> !kvsEntry.getValue().entrySet().isEmpty())
.forEach(kvsEntry -> {
BulkImportColumnType kvType = kvsEntry.getKey();
if (kvType == BulkImportColumnType.SHARED_ATTRIBUTE || kvType == BulkImportColumnType.SERVER_ATTRIBUTE) {

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

@ -34,6 +34,7 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState;
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;
@ -81,6 +82,7 @@ public class CalculatedFieldArgumentUtils {
case GEOFENCING -> new GeofencingCalculatedFieldState(entityId);
case ALARM -> new AlarmCalculatedFieldState(entityId);
case PROPAGATION -> new PropagationCalculatedFieldState(entityId);
case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(entityId);
};
}

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

@ -45,6 +45,8 @@ import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry;
import 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.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
@ -52,6 +54,7 @@ import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculat
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
@ -97,6 +100,11 @@ public class CalculatedFieldUtils {
case SINGLE_VALUE -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) argEntry));
case TS_ROLLING -> builder.addRollingValueArguments(toRollingArgumentProto(argName, (TsRollingArgumentEntry) argEntry));
case GEOFENCING -> builder.addGeofencingArguments(toGeofencingArgumentProto(argName, (GeofencingArgumentEntry) argEntry));
case RELATED_ENTITIES -> {
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = (RelatedEntitiesArgumentEntry) argEntry;
relatedEntitiesArgumentEntry.getEntityInputs()
.forEach((entityId, entry) -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) entry)));
}
}
});
if (state instanceof AlarmCalculatedFieldState alarmState) {
@ -108,6 +116,10 @@ public class CalculatedFieldUtils {
alarmStateProto.setClearRuleState(toAlarmRuleStateProto(alarmState.getClearRuleState()));
}
}
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState aggState) {
builder.setLastArgsUpdateTs(aggState.getLastArgsRefreshTs());
builder.setLastMetricsEvalTs(aggState.getLastMetricsEvalTs());
}
return builder.build();
}
@ -139,6 +151,10 @@ public class CalculatedFieldUtils {
Optional.ofNullable(entry.getVersion()).ifPresent(builder::setVersion);
if (entry.getEntityId() != null) {
builder.setEntityId(ProtoUtils.toProto(entry.getEntityId()));
}
return builder.build();
}
@ -187,8 +203,24 @@ public class CalculatedFieldUtils {
case GEOFENCING -> new GeofencingCalculatedFieldState(id.entityId());
case ALARM -> new AlarmCalculatedFieldState(id.entityId());
case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId());
case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(id.entityId());
};
if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) {
Map<String, Map<EntityId, ArgumentEntry>> arguments = new HashMap<>();
proto.getSingleValueArgumentsList().forEach(argProto -> {
SingleValueArgumentEntry entry = fromSingleValueArgumentProto(argProto);
arguments.computeIfAbsent(argProto.getArgName(), name -> new HashMap<>()).put(entry.getEntityId(), entry);
});
arguments.forEach((argName, entityInputs) -> {
relatedEntitiesAggState.getArguments().put(argName, new RelatedEntitiesArgumentEntry(entityInputs, false));
});
relatedEntitiesAggState.setLastArgsRefreshTs(proto.getLastArgsUpdateTs());
relatedEntitiesAggState.setLastMetricsEvalTs(proto.getLastMetricsEvalTs());
return relatedEntitiesAggState;
}
proto.getSingleValueArgumentsList().forEach(argProto ->
state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto)));
@ -222,11 +254,14 @@ public class CalculatedFieldUtils {
return new SingleValueArgumentEntry();
}
TsValueProto tsValueProto = proto.getValue();
return new SingleValueArgumentEntry(
tsValueProto.getTs(),
(BasicKvEntry) KvProtoUtil.fromTsValueProto(proto.getArgName(), tsValueProto),
proto.getVersion()
);
BasicKvEntry kvEntry = (BasicKvEntry) KvProtoUtil.fromTsValueProto(proto.getArgName(), tsValueProto);
long ts = tsValueProto.getTs();
long version = proto.getVersion();
if (proto.hasEntityId()) {
EntityId entityId = ProtoUtils.fromProto(proto.getEntityId());
return new SingleValueArgumentEntry(entityId, ts, kvEntry, version);
}
return new SingleValueArgumentEntry(ts, kvEntry, version);
}
public static TsRollingArgumentEntry fromRollingArgumentProto(TsRollingArgumentProto proto) {

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

@ -1202,7 +1202,7 @@ transport:
# Enable/disable Bootstrap Server
enabled: "${LWM2M_ENABLED_BS:true}"
# Default value in LwM2M client after start in mode Bootstrap for the object : name "LWM2M Security" field: "Short Server ID" (deviceProfile: Bootstrap.BOOTSTRAP SERVER.Short ID)
id: "${LWM2M_SERVER_ID_BS:111}"
id: "${LWM2M_SERVER_ID_BS:0}"
# LwM2M bootstrap server bind address. Bind to all interfaces by default
bind_address: "${LWM2M_BS_BIND_ADDRESS:0.0.0.0}"
# LwM2M bootstrap server bind port

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

@ -1160,6 +1160,55 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
@Test
public void testCalculatedFieldWhenTheSameTelemetryKeysUsed() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":5}"));
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(testDevice.getId());
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setName("a + b");
calculatedField.setDebugSettings(DebugSettings.all());
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
ReferencedEntityKey refEntityKey = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null);
Argument argumentA = new Argument();
argumentA.setRefEntityKey(refEntityKey);
Argument argumentB = new Argument();
argumentB.setRefEntityKey(refEntityKey);
config.setArguments(Map.of("a", argumentA, "b", argumentB));
config.setExpression("a + b");
Output output = new Output();
output.setName("c");
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
config.setOutput(output);
calculatedField.setConfiguration(config);
doPost("/api/calculatedField", calculatedField, CalculatedField.class);
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode c = getLatestTelemetry(testDevice.getId(), "c");
assertThat(c).isNotNull();
assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("10");
});
doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}"));
await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode c = getLatestTelemetry(testDevice.getId(), "c");
assertThat(c).isNotNull();
assertThat(c.get("c").get(0).get("value").asText()).isEqualTo("20");
});
}
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);

786
application/src/test/java/org/thingsboard/server/cf/RelatedEntitiesAggregationCalculatedFieldTest.java

@ -0,0 +1,786 @@
/**
* 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.ArrayNode;
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.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.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.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.RelatedEntitiesAggregationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration;
import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration;
import org.thingsboard.server.common.data.device.data.DeviceData;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.DeviceProfileId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
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.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL;
@DaoSqlTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractControllerTest {
private Tenant savedTenant;
private DeviceProfile deviceProfile;
private Device device1;
private String accessToken1 = "1234567890111";
private Device device2;
private String accessToken2 = "1234567890222";
private AssetProfile assetProfile;
private Asset asset;
private final long deduplicationInterval = 5;
@Before
public void beforeEach() throws Exception {
loginSysAdmin();
updateDefaultTenantProfileConfig(tenantProfileConfig -> {
tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(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");
deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Device Profile"), DeviceProfile.class);
device1 = createDevice("Device 1", deviceProfile.getId(), accessToken1);
device2 = createDevice("Device 2", deviceProfile.getId(), accessToken2);
postTelemetry(device1.getId(), "{\"occupied\":true}");
postTelemetry(device2.getId(), "{\"occupied\":false}");
assetProfile = doPost("/api/assetProfile", createAssetProfile("Asset Profile"), AssetProfile.class);
asset = createAsset("Asset", assetProfile.getId());
createEntityRelation(asset.getId(), device1.getId(), "Contains");
createEntityRelation(asset.getId(), device2.getId(), "Contains");
}
@After
public void afterTest() throws Exception {
loginSysAdmin();
deleteTenant(savedTenant.getId());
}
@Test
public void testCreateCfOnProfile_checkInitialAggregation() throws Exception {
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
Device device4 = createDevice("Device 4", "1234567890444");
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), device4.getId(), "Contains");
createOccupancyCF(assetProfile.getId());
await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "1",
"totalSpaces", "2"
));
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
postTelemetry(device3.getId(), "{\"occupied\":true}");
await().alias("update telemetry and perform aggregation")
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "1",
"totalSpaces", "2"
));
});
}
@Test
public void testAddEntityToProfile_checkAggregation() throws Exception {
createOccupancyCF(assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
Device device4 = createDevice("Device 4", "1234567890444");
postTelemetry(device3.getId(), "{\"occupied\":true}");
postTelemetry(device4.getId(), "{\"occupied\":true}");
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
await().alias("add entity to profile with no related entities and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode occupancy = getLatestTelemetry(asset2.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces");
assertThat(occupancy).isNotNull();
assertThat(occupancy.get("freeSpaces").get(0).get("value").isNull()).isTrue();
assertThat(occupancy.get("occupiedSpaces").get(0).get("value").isNull()).isTrue();
assertThat(occupancy.get("totalSpaces").get(0).get("value").isNull()).isTrue();
});
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), device4.getId(), "Contains");
await().alias("create relations and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "0",
"occupiedSpaces", "2",
"totalSpaces", "2"
));
});
postTelemetry(device3.getId(), "{\"occupied\":false}");
await().alias("update telemetry and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "1",
"totalSpaces", "2"
));
});
}
@Test
public void testChangeEntityProfile_checkAggregation() throws Exception {
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
Device device4 = createDevice("Device 4", "1234567890444");
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), device4.getId(), "Contains");
createOccupancyCF(assetProfile.getId());
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "1",
"totalSpaces", "2"
));
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
AssetProfile newAssetProfile = createAssetProfile("New Asset Profile");
asset2.setAssetProfileId(newAssetProfile.getId());
doPost("/api/asset", asset2, Asset.class);
postTelemetry(device3.getId(), "{\"occupied\":true}");
await().alias("change profile and no aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
}
@Test
public void testCreateCfOnAssetAndNoTelemetryOnDevices_checkDefaultValueUsed() throws Exception {
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
Device device4 = createDevice("Device 4", "1234567890444");
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), device4.getId(), "Contains");
createOccupancyCF(asset2.getId());
await().alias("create CF and perform aggregation with default values").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
}
@Test
public void testCreateCfAndUpdateTelemetry_checkAggregation() throws Exception {
createOccupancyCF(asset.getId());
checkInitialCalculation();
postTelemetry(device1.getId(), "{\"occupied\":false}");
await().alias("update telemetry and perform aggregation")
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
}
@Test
public void testDeleteCf_checkNoAggregation() throws Exception {
CalculatedField cf = createOccupancyCF(asset.getId());
checkInitialCalculation();
doDelete("/api/calculatedField/" + cf.getId().getId().toString())
.andExpect(status().isOk());
postTelemetry(device1.getId(), "{\"occupied\":false}");
await().alias("delete cf and update telemetry and no aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "1",
"totalSpaces", "2"
));
});
}
@Test
public void testUpdateTelemetry_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception {
createOccupancyCF(asset.getId());
checkInitialCalculation();
postTelemetry(device1.getId(), "{\"occupied\":false}");
await().alias("update telemetry -> no changes").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(this::checkInitialCalculationValues);
postTelemetry(device2.getId(), "{\"occupied\":false}");
await().alias("create CF and perform initial calculation")
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
.atMost(TIMEOUT, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
}
@Test
public void testDeleteTelemetry_checkAggregationWithPreviousValuesOrDefault() throws Exception {
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
Device device4 = createDevice("Device 4", "1234567890444");
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), device4.getId(), "Contains");
long currentTime = System.currentTimeMillis();
long firstTs = currentTime - 10;
long secondTs = currentTime - 10;
long thirdTs = currentTime - 5;
postTelemetry(device3.getId(), "{\"ts\": " + firstTs + ", \"values\": {\"occupied\":true}}");
postTelemetry(device4.getId(), "{\"ts\": " + secondTs + ", \"values\": {\"occupied\":true}}");
postTelemetry(device3.getId(), "{\"ts\": " + thirdTs + ", \"values\": {\"occupied\":true}}");
createOccupancyCF(asset2.getId());
await().alias("create CF and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "0",
"occupiedSpaces", "2",
"totalSpaces", "2"
));
});
doDelete("/api/plugins/telemetry/DEVICE/" + device3.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + thirdTs + "&endTs=" + thirdTs + 1, String.class);
doDelete("/api/plugins/telemetry/DEVICE/" + device4.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + secondTs + "&endTs=" + secondTs + 1, String.class);
await().alias("delete latest telemetry and perform aggregation with previous or default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "1",
"totalSpaces", "2"
));
});
}
@Test
public void testDeleteAttr_checkAggregationWithDefault() throws Exception {
Asset asset2 = createAsset("Asset 2", assetProfile.getId());
Device device3 = createDevice("Device 3", "1234567890333");
Device device4 = createDevice("Device 4", "1234567890444");
createEntityRelation(asset2.getId(), device3.getId(), "Contains");
createEntityRelation(asset2.getId(), device4.getId(), "Contains");
postAttributes(device3.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}");
postAttributes(device4.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}");
createOccupancyCFWithAttr(asset2.getId());
await().alias("create CF and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "0",
"occupiedSpaces", "2",
"totalSpaces", "2"
));
});
doDelete("/api/plugins/telemetry/DEVICE/" + device3.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class);
doDelete("/api/plugins/telemetry/DEVICE/" + device4.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class);
await().alias("delete attribute and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset2.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
}
@Test
public void testCreateRelation_checkAggregation() throws Exception {
createOccupancyCF(asset.getId());
checkInitialCalculation();
Device device3 = createDevice("Device 3", deviceProfile.getId(), "1234567890333");
postTelemetry(device3.getId(), "{\"occupied\":true}");
createEntityRelation(asset.getId(), device3.getId(), "Contains");
await().alias("create relation and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "2",
"totalSpaces", "3"
));
});
}
@Test
public void testDeleteRelation_checkAggregation() throws Exception {
createOccupancyCF(asset.getId());
checkInitialCalculation();
deleteEntityRelation(new EntityRelation(asset.getId(), device1.getId(), "Contains", RelationTypeGroup.COMMON));
await().alias("create relation and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "1",
"occupiedSpaces", "0",
"totalSpaces", "1"
));
});
}
@Test
public void testUpdateRelationPath_checkAggregation() throws Exception {
CalculatedField cf = createOccupancyCF(asset.getId());
checkInitialCalculation();
Device device3 = createDevice("Device 3", "1234567890333");
createEntityRelation(asset.getId(), device3.getId(), "Has");
postTelemetry(device3.getId(), "{\"occupied\":true}");
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
configuration.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, "Has"));
saveCalculatedField(cf);
await().alias("update relation path and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "0",
"occupiedSpaces", "1",
"totalSpaces", "1"
));
});
}
@Test
public void testUpdateArguments_checkAggregation() throws Exception {
CalculatedField cf = createOccupancyCF(asset.getId());
checkInitialCalculation();
postTelemetry(device1.getId(), "{\"occupiedStatus\":false}");
postTelemetry(device2.getId(), "{\"occupiedStatus\":false}");
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("oc", ArgumentType.TS_LATEST, null));
argument.setDefaultValue("false");
configuration.setArguments(Map.of("oc", argument));
saveCalculatedField(cf);
await().alias("update arguments and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of(
"freeSpaces", "2",
"occupiedSpaces", "0",
"totalSpaces", "2"
));
});
}
@Test
public void testUpdateMetrics_checkAggregation() throws Exception {
postTelemetry(device1.getId(), "{\"temperature\":24.2}");
postTelemetry(device2.getId(), "{\"temperature\":19.6}");
CalculatedField cf = createAvgTemperatureCF(asset.getId());
await().alias("create avg temp cf and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
});
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
AggMetric aggMetric = new AggMetric();
aggMetric.setInput(new AggKeyInput("temp"));
aggMetric.setFilter("return temp < 100;");
aggMetric.setFunction(AggFunction.MAX);
configuration.setMetrics(Map.of("maxTemperature", aggMetric));
saveCalculatedField(cf);
await().alias("update metrics and perform aggregation").atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("maxTemperature", "24"));
});
postTelemetry(device1.getId(), "{\"temperature\":101.3}");
postTelemetry(device2.getId(), "{\"temperature\":25.8}");
await().alias("update telemetry and perform aggregation")
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS)
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("maxTemperature", "26"));
});
}
@Test
public void testUpdateOutput_checkAggregation() throws Exception {
postTelemetry(device1.getId(), "{\"temperature\":24.2}");
postTelemetry(device2.getId(), "{\"temperature\":19.6}");
CalculatedField cf = createAvgTemperatureCF(asset.getId());
await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
});
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
Output output = new Output();
output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
configuration.setOutput(output);
saveCalculatedField(cf);
await().alias("update output and perform aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode avgTemperature = getServerAttributes(asset.getId(), "avgTemperature");
assertThat(avgTemperature).isNotNull();
assertThat(avgTemperature.get(0)).isNotNull();
assertThat(avgTemperature.get(0).get("value").asText()).isEqualTo("24.2");
});
}
@Test
public void testUpdateDeduplicationInterval_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception {
postTelemetry(device1.getId(), "{\"temperature\":24.2}");
postTelemetry(device2.getId(), "{\"temperature\":19.6}");
CalculatedField cf = createAvgTemperatureCF(asset.getId());
await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
});
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration();
configuration.setDeduplicationIntervalInSec(2 * deduplicationInterval);
saveCalculatedField(cf);
await().alias("update deduplication interval and perform aggregation").atMost(deduplicationInterval / 2, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24"));
});
postTelemetry(device2.getId(), "{\"temperature\":32.1}");
await().alias("update telemetry and perform aggregation").atMost(2 * deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "28"));
});
}
private void checkInitialCalculation() {
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(this::checkInitialCalculationValues);
}
private void checkInitialCalculationValues() throws Exception {
ObjectNode occupancy = getLatestTelemetry(asset.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces");
assertThat(occupancy).isNotNull();
assertThat(occupancy.get("freeSpaces").get(0).get("value").asText()).isEqualTo("1");
assertThat(occupancy.get("occupiedSpaces").get(0).get("value").asText()).isEqualTo("1");
assertThat(occupancy.get("totalSpaces").get(0).get("value").asText()).isEqualTo("2");
}
private CalculatedField createAvgTemperatureCF(EntityId entityId) {
Map<String, Argument> arguments = new HashMap<>();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
argument.setDefaultValue("20");
arguments.put("temp", argument);
Map<String, AggMetric> aggMetrics = new HashMap<>();
AggMetric avgMetric = new AggMetric();
avgMetric.setFunction(AggFunction.AVG);
avgMetric.setFilter("return temp >= 20;");
avgMetric.setInput(new AggKeyInput("temp"));
aggMetrics.put("avgTemperature", avgMetric);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
return createAggCf("Average temperature", entityId,
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
arguments,
aggMetrics,
output);
}
private CalculatedField createOccupancyCF(EntityId entityId) {
Map<String, Argument> arguments = new HashMap<>();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null));
argument.setDefaultValue("false");
arguments.put("oc", argument);
Map<String, AggMetric> aggMetrics = new HashMap<>();
AggMetric freeSpaces = new AggMetric();
freeSpaces.setFunction(AggFunction.COUNT);
freeSpaces.setFilter("return oc == false;");
freeSpaces.setInput(new AggKeyInput("oc"));
aggMetrics.put("freeSpaces", freeSpaces);
AggMetric occupiedSpaces = new AggMetric();
occupiedSpaces.setFunction(AggFunction.COUNT);
occupiedSpaces.setFilter("return oc == true;");
occupiedSpaces.setInput(new AggKeyInput("oc"));
aggMetrics.put("occupiedSpaces", occupiedSpaces);
AggMetric totalSpaces = new AggMetric();
totalSpaces.setFunction(AggFunction.COUNT);
totalSpaces.setInput(new AggFunctionInput("return 1;"));
aggMetrics.put("totalSpaces", totalSpaces);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
return createAggCf("Occupied spaces", entityId,
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
arguments,
aggMetrics,
output);
}
private CalculatedField createOccupancyCFWithAttr(EntityId entityId) {
Map<String, Argument> arguments = new HashMap<>();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
argument.setDefaultValue("false");
arguments.put("oc", argument);
Map<String, AggMetric> aggMetrics = new HashMap<>();
AggMetric freeSpaces = new AggMetric();
freeSpaces.setFunction(AggFunction.COUNT);
freeSpaces.setFilter("return oc == false;");
freeSpaces.setInput(new AggKeyInput("oc"));
aggMetrics.put("freeSpaces", freeSpaces);
AggMetric occupiedSpaces = new AggMetric();
occupiedSpaces.setFunction(AggFunction.COUNT);
occupiedSpaces.setFilter("return oc == true;");
occupiedSpaces.setInput(new AggKeyInput("oc"));
aggMetrics.put("occupiedSpaces", occupiedSpaces);
AggMetric totalSpaces = new AggMetric();
totalSpaces.setFunction(AggFunction.COUNT);
totalSpaces.setInput(new AggFunctionInput("return 1;"));
aggMetrics.put("totalSpaces", totalSpaces);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
output.setDecimalsByDefault(0);
return createAggCf("Occupied spaces", entityId,
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"),
arguments,
aggMetrics,
output);
}
private CalculatedField createAggCf(String name,
EntityId entityId,
RelationPathLevel relation,
Map<String, Argument> inputs,
Map<String, AggMetric> metrics,
Output output) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setName(name);
calculatedField.setEntityId(entityId);
calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION);
RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = new RelatedEntitiesAggregationCalculatedFieldConfiguration();
configuration.setRelation(relation);
configuration.setArguments(inputs);
configuration.setDeduplicationIntervalInSec(deduplicationInterval);
configuration.setMetrics(metrics);
configuration.setOutput(output);
calculatedField.setConfiguration(configuration);
calculatedField.setDebugSettings(DebugSettings.all());
return saveCalculatedField(calculatedField);
}
private Device createDevice(String name, DeviceProfileId deviceProfileId, String accessToken) {
Device device = new Device();
device.setName(name);
device.setDeviceProfileId(deviceProfileId);
DeviceData deviceData = new DeviceData();
deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration());
deviceData.setConfiguration(new DefaultDeviceConfiguration());
device.setDeviceData(deviceData);
return doPost("/api/device?accessToken=" + accessToken, device, Device.class);
}
private Asset createAsset(String name, AssetProfileId assetProfileId) {
Asset asset = new Asset();
asset.setName(name);
asset.setAssetProfileId(assetProfileId);
return doPost("/api/asset", asset, Asset.class);
}
private void verifyTelemetry(EntityId entityId, Map<String, String> expectedResults) throws Exception {
ObjectNode result = getLatestTelemetry(entityId, expectedResults.keySet().toArray(new String[0]));
assertThat(result).isNotNull();
expectedResults.forEach((key, value) -> assertThat(result.get(key).get(0).get("value").asText()).isEqualTo(value));
}
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);
}
private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception {
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class);
}
}

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

@ -148,6 +148,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.msg.session.FeatureType;
@ -215,7 +216,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected static final String TENANT_ADMIN_PASSWORD = "tenant";
protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org";
private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org";
private static final String CUSTOMER_USER_PASSWORD = "customer";
@ -608,8 +609,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
Assert.assertNotNull(tokenInfo);
Assert.assertTrue(tokenInfo.has("token"));
Assert.assertTrue(tokenInfo.has("refreshToken"));
String token = tokenInfo.get("token").asText();
String refreshToken = tokenInfo.get("refreshToken").asText();
validateAndSetJwtToken(JacksonUtil.treeToValue(tokenInfo, JwtPair.class), username);
}
protected void validateAndSetJwtToken(JwtPair jwtPair, String username) {
Assert.assertNotNull(jwtPair);
String token = jwtPair.getToken();
String refreshToken = jwtPair.getRefreshToken();
validateJwtToken(token, username);
validateJwtToken(refreshToken, username);
this.token = token;
@ -1063,6 +1069,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
doPost("/api/relation", relation);
}
protected void deleteEntityRelation(EntityRelation entityRelation) throws Exception {
String url = String.format("/api/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
entityRelation.getFrom().getId(),
entityRelation.getFrom().getEntityType(),
entityRelation.getType(),
entityRelation.getTo().getId(),
entityRelation.getTo().getEntityType());
doDelete(url);
}
protected List<EntityRelation> findRelationsByTo(EntityId entityId) throws Exception {
String url = String.format("/api/relations?toId=%s&toType=%s", entityId.getId(), entityId.getEntityType().name());
MvcResult mvcResult = doGet(url).andReturn();

45
application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java

@ -394,6 +394,48 @@ public class DeviceControllerTest extends AbstractControllerTest {
.andExpect(statusReason(containsString("Device can`t be referencing to device profile from different tenant!")));
}
@Test
public void testSaveDeviceWithFirmware() throws Exception {
loginTenantAdmin();
DeviceProfile profile = createDeviceProfile("Profile to test ota updates");
profile = doPost("/api/deviceProfile", profile, DeviceProfile.class);
SaveOtaPackageInfoRequest firmwareInfo = new SaveOtaPackageInfoRequest();
firmwareInfo.setDeviceProfileId(profile.getId());
firmwareInfo.setType(FIRMWARE);
String title = "title";
firmwareInfo.setTitle(title);
String fwVersion = "1.0";
firmwareInfo.setVersion(fwVersion);
String url = "test.url";
firmwareInfo.setUrl(url);
firmwareInfo.setUsesUrl(true);
OtaPackageInfo savedFw = doPost("/api/otaPackage", firmwareInfo, OtaPackageInfo.class);
Device device = new Device();
device.setName("My ota device");
device.setDeviceProfileId(profile.getId());
device.setFirmwareId(savedFw.getId());
device = doPost("/api/device", device, Device.class);
//check shared attributes
Device finalDevice = device;
await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> {
List<Map<String, Object>> attributes = doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + finalDevice.getId() +
"/values/attributes/SHARED_SCOPE", new TypeReference<List<Map<String, Object>>>() {
});
return findAttrValue("fw_version", attributes).equals(fwVersion) &&
findAttrValue("fw_title", attributes).equals(title) &&
findAttrValue("fw_url", attributes).equals(url);
});
}
private static Object findAttrValue(String key, List<Map<String, Object>> attributes) {
Optional<Map<String, Object>> attr = attributes.stream()
.filter(att -> att.get("key").equals(key)).findFirst();
return attr.isPresent() ? attr.get().get("value") : "";
}
@Test
public void testSaveDeviceWithFirmwareFromDifferentTenant() throws Exception {
loginDifferentTenant();
@ -1638,7 +1680,8 @@ public class DeviceControllerTest extends AbstractControllerTest {
Assert.assertEquals(deviceName, savedDevice.getName());
Assert.assertEquals(deviceType, savedDevice.getType());
Optional<AttributeKvEntry> retrieved = attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get();
Optional<AttributeKvEntry> retrieved = await().atMost(5, TimeUnit.SECONDS)
.until(() -> attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get(), Optional::isPresent);
assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr);
assertThat(retrieved.get().getStrValue()).isNotPresent();
}

23
application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java

@ -243,6 +243,29 @@ public class TenantControllerTest extends AbstractControllerTest {
.andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr))));
}
@Test
public void testDeleteTenantByTenantAdmin() throws Exception {
loginSysAdmin();
Tenant tenant = new Tenant();
tenant.setTitle("My tenant");
Tenant savedTenant = saveTenant(tenant);
//login as tenant admin
User tenantAdminUser = new User();
tenantAdminUser.setAuthority(Authority.TENANT_ADMIN);
tenantAdminUser.setTenantId(savedTenant.getId());
tenantAdminUser.setEmail("tenantToDelete@thingsboard.io");
createUserAndLogin(tenantAdminUser, TENANT_ADMIN_PASSWORD);
String tenantIdStr = savedTenant.getId().getId().toString();
deleteTenant(savedTenant.getId());
loginSysAdmin();
doGet("/api/tenant/" + tenantIdStr)
.andExpect(status().isNotFound())
.andExpect(statusReason(containsString(msgErrorNoFound("Tenant", tenantIdStr))));
}
@Test
public void testFindTenants() throws Exception {
loginSysAdmin();

152
application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java

@ -30,6 +30,8 @@ import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig;
@ -48,6 +50,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFaP
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@ -85,7 +88,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaConfigManager.deletePlatformTwoFaSettings(tenantId);
}
@Test
public void testSavePlatformTwoFaSettings() throws Exception {
loginSysAdmin();
@ -102,15 +104,32 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaSettings.setVerificationCodeCheckRateLimit("3:900");
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
twoFaSettings.setTotalAllowedTimeForVerification(3600);
twoFaSettings.setEnforceTwoFa(true);
twoFaSettings.setEnforcedUsersFilter(new AllUsersFilter());
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
saveTwoFaSettings(twoFaSettings);
PlatformTwoFaSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class);
PlatformTwoFaSettings savedTwoFaSettings = findTwoFaSettings();
assertThat(savedTwoFaSettings.getProviders()).hasSize(2);
assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig);
}
@Test
public void testSavePlatformTwoFaSettingsWithEnforceTwoFaWithoutProviders() throws Exception {
loginSysAdmin();
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(List.of());
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setVerificationCodeCheckRateLimit("3:900");
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
twoFaSettings.setTotalAllowedTimeForVerification(3600);
twoFaSettings.setEnforceTwoFa(true);
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isBadRequest());
}
@Test
public void testSavePlatformTwoFaSettings_validationError() throws Exception {
loginSysAdmin();
@ -157,17 +176,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(errorResponse).containsIgnoringCase("verificationCodeLifetime is required");
}
private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception {
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings)
.andExpect(status().isBadRequest()));
}
@Test
public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception {
configureSmsTwoFaProvider("${code}");
@ -268,24 +276,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(errorMessage).containsIgnoringCase("verification code is incorrect");
}
private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception {
TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP")
.andExpect(status().isOk()), TwoFaAccountConfig.class);
assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class);
assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> {
UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build();
assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth");
assertThat(otpAuthUrl.getHost()).isEqualTo("totp");
assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName());
assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL);
assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> {
assertDoesNotThrow(() -> Base32.decode(secretKey));
});
});
return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig;
}
@Test
public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception {
testVerifyAndSaveTotpTwoFaAccountConfig();
@ -419,6 +409,56 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig);
}
@Test
public void testIsTwoFaEnabled() throws Exception {
configureSmsTwoFaProvider("${code}");
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig);
assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUser)).isTrue();
}
@Test
public void testDeleteTwoFaAccountConfig() throws Exception {
configureSmsTwoFaProvider("${code}");
loginTenantAdmin();
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig);
AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class);
TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS);
assertThat(savedAccountConfig).isEqualTo(accountConfig);
PlatformTwoFaSettings twoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true).get();
twoFaSettings.setEnforceTwoFa(true);
TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter();
enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId()));
twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
String errorMessage = getErrorMessage(doDelete("/api/2fa/account/config?providerType=SMS")
.andExpect(status().isBadRequest()));
assertThat(errorMessage).isEqualTo("At least one 2FA provider is required");
twoFaSettings.setEnforceTwoFa(false);
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk());
assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs())
.doesNotContainKey(TwoFaProviderType.SMS);
}
private PlatformTwoFaSettings findTwoFaSettings() throws Exception {
return doGet("/api/2fa/settings", PlatformTwoFaSettings.class);
}
private void saveTwoFaSettings(PlatformTwoFaSettings twoFaSettings) throws Exception {
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
}
private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception {
TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
totpTwoFaProviderConfig.setIssuerName("tb");
@ -441,37 +481,35 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList()));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
saveTwoFaSettings(twoFaSettings);
}
@Test
public void testIsTwoFaEnabled() throws Exception {
configureSmsTwoFaProvider("${code}");
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig);
private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception {
TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP")
.andExpect(status().isOk()), TwoFaAccountConfig.class);
assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class);
assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue();
assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> {
UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build();
assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth");
assertThat(otpAuthUrl.getHost()).isEqualTo("totp");
assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName());
assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL);
assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> {
assertDoesNotThrow(() -> Base32.decode(secretKey));
});
});
return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig;
}
@Test
public void testDeleteTwoFaAccountConfig() throws Exception {
configureSmsTwoFaProvider("${code}");
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
loginTenantAdmin();
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig);
AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class);
TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS);
assertThat(savedAccountConfig).isEqualTo(accountConfig);
doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk());
private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception {
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs())
.doesNotContainKey(TwoFaProviderType.SMS);
return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings)
.andExpect(status().isBadRequest()));
}
}

74
application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java

@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
@ -33,6 +34,7 @@ import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.common.data.page.TimePageLink;
@ -58,6 +60,7 @@ import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@ -116,7 +119,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_totp() throws Exception {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=TOTP")
.andExpect(status().isOk());
@ -136,7 +139,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_sms() throws Exception {
configureSmsTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=SMS")
.andExpect(status().isOk());
@ -160,7 +163,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setTotalAllowedTimeForVerification(65);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
await("expiration of the pre-verification token")
.atLeast(Duration.ofSeconds(30).plusMillis(500))
@ -177,7 +180,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
Stream.generate(() -> StringUtils.randomNumeric(6))
.limit(9)
@ -206,7 +209,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setMinVerificationCodeSendPeriod(10);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=TOTP")
.andExpect(status().isOk());
@ -230,7 +233,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setVerificationCodeCheckRateLimit("3:10");
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
for (int i = 0; i < 3; i++) {
String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect")
@ -258,7 +261,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
@Test
public void testCheckVerificationCode_invalidVerificationCode() throws Exception {
configureTotpTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) {
String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode)
@ -273,7 +276,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
smsTwoFaProviderConfig.setVerificationCodeLifetime(10);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
ArgumentCaptor<String> verificationCodeCaptor = ArgumentCaptor.forClass(String.class);
doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk());
@ -296,7 +299,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_logLoginAction() throws Exception {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
await("async audit log saving").during(1, TimeUnit.SECONDS);
doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect")
@ -334,7 +337,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
@Test
public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException {
configureTotpTwoFa();
twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP);
twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user, TwoFaProviderType.TOTP);
assertDoesNotThrow(() -> {
login(username, password);
@ -368,17 +371,17 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP);
totpTwoFaAccountConfig.setUseByDefault(true);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, totpTwoFaAccountConfig);
SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig();
smsTwoFaAccountConfig.setPhoneNumber("+38012312322");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, smsTwoFaAccountConfig);
EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig();
emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail());
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, emailTwoFaAccountConfig);
logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678");
logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN);
Map<TwoFaProviderType, TwoFactorAuthController.TwoFaProviderInfo> providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference<List<TwoFactorAuthController.TwoFaProviderInfo>>() {}).stream()
.collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v));
@ -395,13 +398,48 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse();
}
private void logInWithPreVerificationToken(String username, String password) throws Exception {
@Test
public void testEnforceTwoFa() throws Exception {
TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
totpTwoFaProviderConfig.setIssuerName("tb");
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList()));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
twoFaSettings.setEnforceTwoFa(true);
TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter();
enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId()));
twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
logInWithMfaToken(username, password, Authority.MFA_CONFIGURATION_TOKEN);
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, totpTwoFaProviderConfig.getProviderType());
String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build()
.getQueryParams().getFirst("secret");
String verificationCode = new Totp(secret).now();
readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class);
JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class);
assertThat(tokenPair.getToken()).isNotEmpty();
assertThat(tokenPair.getRefreshToken()).isNotEmpty();
validateAndSetJwtToken(tokenPair, username);
doGet("/api/user/" + user.getId()).andExpect(status().isOk());
// verifying enforced users filter
createDifferentTenant();
doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk());
}
private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception {
LoginRequest loginRequest = new LoginRequest(username, password);
JwtPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtPair.class);
assertThat(response.getToken()).isNotNull();
assertThat(response.getRefreshToken()).isNull();
assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN);
assertThat(response.getScope()).isEqualTo(expectedScope);
this.token = response.getToken();
}
@ -418,7 +456,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, totpTwoFaAccountConfig);
return totpTwoFaAccountConfig;
}
@ -436,7 +474,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig();
smsTwoFaAccountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, smsTwoFaAccountConfig);
return smsTwoFaAccountConfig;
}

100
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesArgumentEntryTest.java

@ -0,0 +1,100 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class RelatedEntitiesArgumentEntryTest {
private RelatedEntitiesArgumentEntry entry;
private final DeviceId device1 = new DeviceId(UUID.fromString("1984e5f4-9ff0-4187-84ae-e4438bba4c8a"));
private final DeviceId device2 = new DeviceId(UUID.fromString("937fc062-1a9d-438f-aa22-55a93fc908b7"));
private final long ts = System.currentTimeMillis();
@BeforeEach
void setUp() {
Map<EntityId, ArgumentEntry> aggInputs = new HashMap<>();
aggInputs.put(device1, new SingleValueArgumentEntry(device1, new BasicTsKvEntry(ts - 100, new LongDataEntry("key", 12L), 1L)));
aggInputs.put(device2, new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 150, new LongDataEntry("key", 16L), 6L)));
entry = new RelatedEntitiesArgumentEntry(aggInputs, false);
}
@Test
void testUpdateEntryWhenNotAggEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING);
}
@Test
void testUpdateEntryWhenAggArgumentEntryPasser() {
DeviceId device3 = new DeviceId(UUID.randomUUID());
DeviceId device4 = new DeviceId(UUID.randomUUID());
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = new RelatedEntitiesArgumentEntry(Map.of(
device3, new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 16L), 13L)),
device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L))
), false);
assertThat(entry.updateEntry(relatedEntitiesArgumentEntry)).isTrue();
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs();
assertThat(aggInputs.size()).isEqualTo(4);
assertThat(aggInputs.get(device3)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device3));
assertThat(aggInputs.get(device4)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device4));
}
@Test
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndNoEntriesById() {
DeviceId device3 = new DeviceId(UUID.randomUUID());
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L));
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue();
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs();
assertThat(aggInputs.size()).isEqualTo(3);
assertThat(aggInputs.get(device3)).isEqualTo(singleEntityArgumentEntry);
}
@Test
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() {
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L));
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue();
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs();
assertThat(aggInputs.size()).isEqualTo(2);
assertThat(aggInputs.get(device2)).isEqualTo(singleEntityArgumentEntry);
}
}

2
application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java

@ -125,7 +125,7 @@ public class JwtTokenFactoryTest {
public void testCreateAndParsePreVerificationJwtToken() {
SecurityUser securityUser = createSecurityUser();
int tokenLifetime = (int) TimeUnit.MINUTES.toSeconds(30);
JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime);
JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime);
checkExpirationTime(preVerificationToken, tokenLifetime);
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken());

1
application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java

@ -64,6 +64,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
"coap.server.enabled=true",
"coap.dtls.enabled=true",
"coap.dtls.credentials.pem.cert_file=coap/credentials/server/cert.pem",
"coap.dtls.x509.skip_validity_check_for_client_cert=true",
"device.connectivity.coaps.enabled=true",
"service.integrations.supported=ALL",
"transport.coap.enabled=true",

43
application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java

@ -84,6 +84,7 @@ import org.thingsboard.server.transport.lwm2m.server.client.ResourceUpdateResult
import org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler;
import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler;
import java.io.IOException;
import java.net.ServerSocket;
import java.util.ArrayList;
import java.util.Arrays;
@ -120,11 +121,11 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfil
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
import static org.thingsboard.server.transport.lwm2m.ota.AbstractOtaLwM2MIntegrationTest.CLIENT_LWM2M_SETTINGS_19;
@TestPropertySource(properties = {
"transport.lwm2m.enabled=true",
})
@Slf4j
@DaoSqlTest
@TestPropertySource(properties = {
"transport.lwm2m.enabled=true"
})
public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportIntegrationTest {
@SpyBean
@ -145,9 +146,6 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte
public static final String host = "localhost";
public static final String hostBs = "localhost";
public static final Integer shortServerId = 123;
public static final Integer shortServerIdBs0 = 0;
public static final int serverId = 1;
public static final int serverIdBs = 0;
public static final String COAP = "coap://";
public static final String COAPS = "coaps://";
@ -317,7 +315,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte
@After
public void after() throws Exception {
this.clientDestroy(true);
this.clientDestroy();
if (executor != null && !executor.isShutdown()) {
executor.shutdownNow();
}
@ -564,7 +562,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte
public void createNewClient(Security security, Security securityBs, boolean isRpc,
String endpoint, Integer clientDtlsCidLength, boolean queueMode,
String deviceIdStr, Integer value3_0_9) throws Exception {
this.clientDestroy(false);
this.clientDestroy();
lwM2MTestClient = new LwM2MTestClient(this.executor, endpoint, resources);
try (ServerSocket socket = new ServerSocket(0)) {
@ -651,13 +649,30 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte
}
private void clientDestroy(boolean isAfter) {
private void clientDestroy() {
try {
if (lwM2MTestClient != null && lwM2MTestClient.getLeshanClient() != null) {
if (isAfter) {
sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr());
awaitDeleteDevice(lwM2MTestClient.getDeviceIdStr());
boolean serverAlive = false;
for (int port = AbstractLwM2MIntegrationTest.port; port <= securityPortBs; port++) {
try (ServerSocket socket = new ServerSocket(port)) {
log.info("Port {} is free.", port);
} catch (IOException e) {
log.debug("Port {} is busy — CoAP server still active.", port);
serverAlive = true;
break;
}
}
if (serverAlive) {
try {
sendObserveCancelAllWithAwait(lwM2MTestClient.getDeviceIdStr());
awaitDeleteDevice(lwM2MTestClient.getDeviceIdStr());
} catch (Exception e) {
log.warn("Failed to cleanup LwM2M observations before destroy: {}", e.getMessage());
}
} else {
log.info("No active CoAP server found on ports 5685–5688. Skipping observe cleanup.");
}
lwM2MTestClient.destroy();
}
} catch (Exception e) {
@ -705,10 +720,10 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte
return bootstrap;
}
private AbstractLwM2MBootstrapServerCredential getBootstrapServerCredentialNoSec(boolean isBootstrap) {
protected AbstractLwM2MBootstrapServerCredential getBootstrapServerCredentialNoSec(boolean isBootstrap) {
AbstractLwM2MBootstrapServerCredential bootstrapServerCredential = new NoSecLwM2MBootstrapServerCredential();
bootstrapServerCredential.setServerPublicKey("");
bootstrapServerCredential.setShortServerId(isBootstrap ? shortServerIdBs0 : shortServerId);
bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId);
bootstrapServerCredential.setBootstrapServerIs(isBootstrap);
bootstrapServerCredential.setHost(isBootstrap ? hostBs : host);
bootstrapServerCredential.setPort(isBootstrap ? portBs : port);

2
application/src/test/java/org/thingsboard/server/transport/lwm2m/Lwm2mTestHelper.java

@ -24,8 +24,6 @@ public class Lwm2mTestHelper {
public static final int TEMPERATURE_SENSOR = 3303;
// Ids in Client
public static final int OBJECT_ID_0 = 0;
public static final int OBJECT_ID_1 = 1;
public static final int OBJECT_INSTANCE_ID_0 = 0;
public static final int OBJECT_INSTANCE_ID_1 = 1;
public static final int OBJECT_INSTANCE_ID_2 = 2;

174
application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java

@ -68,6 +68,9 @@ import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandle
import org.thingsboard.server.transport.lwm2m.utils.LwM2mValueConverterImpl;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.HashMap;
@ -77,6 +80,7 @@ import java.util.Map;
import java.util.Set;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CONNECTION_ID_LENGTH;
import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY;
@ -88,10 +92,7 @@ import static org.eclipse.leshan.core.LwM2mId.SECURITY;
import static org.eclipse.leshan.core.LwM2mId.SERVER;
import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT;
import static org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder.getDefaultPathEncoder;
import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverId;
import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.serverIdBs;
import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerId;
import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerIdBs0;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_BOOTSTRAP_FAILURE;
@ -141,7 +142,7 @@ public class LwM2MTestClient {
private LwM2mTemperatureSensor lwM2mTemperatureSensor12;
private String deviceIdStr;
public void init(Security security, Security securityBs, int port, boolean isRpc,
public void init(Security securityLwm2m, Security securityBs, int port, boolean isRpc,
LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler,
LwM2mClientContext clientContext, Integer cIdLength, boolean queueMode,
boolean supportFormatOnly_SenMLJSON_SenMLCBOR, Integer value3_0_9) throws InvalidDDFFileException, IOException {
@ -149,55 +150,33 @@ public class LwM2MTestClient {
this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler;
this.clientContext = clientContext;
List<ObjectModel> models = ObjectLoader.loadAllDefault();
for (String resourceName : lwm2mClientResources) {
models.addAll(ObjectLoader.loadDdfFile(LwM2MTestClient.class.getClassLoader().getResourceAsStream("lwm2m/" + resourceName), resourceName));
ObjectsInitializer initializer = createFreshInitializer();
// SECURITY
if (securityLwm2m != null && securityLwm2m.getId() != null) {
forceNullSecurityId(securityLwm2m);
}
if (this.modelResources != null) {
List<ObjectModel> modelsRes = new ArrayList<>();
for (String resourceName : this.modelResources) {
modelsRes.addAll(ObjectLoader.loadDdfFile(LwM2MTestClient.class.getClassLoader().getResourceAsStream("lwm2m/" + resourceName), resourceName));
}
Set<Integer> idsToRemove = new HashSet<>();
for (ObjectModel model : modelsRes) {
idsToRemove.add(model.id);
}
models.removeIf(model -> idsToRemove.contains(model.id));
models.addAll(modelsRes);
if (securityBs!= null && securityBs.getId() != null) {
forceNullSecurityId(securityBs);
}
LwM2mModel model = new StaticModel(models);
ObjectsInitializer initializer = new ObjectsInitializer(model);
if (securityBs != null && security != null) {
// SECURITY
security.setId(serverId);
securityBs.setId(serverIdBs);
LwM2mInstanceEnabler[] instances = new LwM2mInstanceEnabler[]{securityBs, security};
initializer.setClassForObject(SECURITY, Security.class);
initializer.setInstancesForObject(SECURITY, instances);
// SERVER
Server lwm2mServer = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60));
lwm2mServer.setId(serverId);
Server serverBs = new Server(shortServerIdBs0, TimeUnit.MINUTES.toSeconds(60));
serverBs.setId(serverIdBs);
instances = new LwM2mInstanceEnabler[]{serverBs, lwm2mServer};
initializer.setClassForObject(SERVER, Server.class);
initializer.setInstancesForObject(SERVER, instances);
if (securityBs != null && securityLwm2m != null) {
log.warn("Security Both: securityBs: [{}] and security Lwm2m [{}]", securityBs.getId(), securityLwm2m.getId());
initializer.setInstancesForObject(SECURITY, securityBs, securityLwm2m);
} else if (securityBs != null) {
// SECURITY
log.warn("Security BS only: securityBs: [{}] ", securityBs.getId());
initializer.setInstancesForObject(SECURITY, securityBs);
// SERVER
initializer.setClassForObject(SERVER, Server.class);
} else {
} else if (securityLwm2m != null){
// SECURITY
initializer.setInstancesForObject(SECURITY, security);
// SERVER
Server lwm2mServer = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60));
lwm2mServer.setId(serverId);
initializer.setInstancesForObject(SERVER, lwm2mServer);
log.warn("Security Lwm2m only: security Lwm2m [{}]", securityLwm2m.getId());
initializer.setInstancesForObject(SECURITY, securityLwm2m);
}
// SERVER
Server serverLwm2m = new Server(shortServerId, TimeUnit.MINUTES.toSeconds(60));
initializer.setInstancesForObject(SERVER, serverLwm2m);
// DEVICE
initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor, value3_0_9));
// OTHER t
initializer.setInstancesForObject(FIRMWARE, fwLwM2MDevice = new FwLwM2MDevice());
initializer.setInstancesForObject(SOFTWARE_MANAGEMENT, swLwM2MDevice = new SwLwM2MDevice());
initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class);
@ -444,25 +423,43 @@ public class LwM2MTestClient {
public void destroy() {
if (leshanClient != null) {
leshanClient.destroy(true);
}
if (lwM2MDevice != null) {
lwM2MDevice.destroy();
}
if (fwLwM2MDevice != null) {
fwLwM2MDevice.destroy();
}
if (swLwM2MDevice != null) {
swLwM2MDevice.destroy();
}
if (lwM2MBinaryAppDataContainer != null) {
lwM2MBinaryAppDataContainer.destroy();
try {
leshanClient.destroy(true);
} catch (Exception e) {
log.warn("Failed to destroy Leshan client", e);
} finally {
leshanClient = null;
}
}
if (lwM2MTemperatureSensor != null) {
lwM2MTemperatureSensor.destroy();
// ThingsBoard custom LwM2M objects
destroySafe(lwM2MDevice);
destroySafe(fwLwM2MDevice);
destroySafe(swLwM2MDevice);
destroySafe(lwM2MBinaryAppDataContainer);
destroySafe(lwM2MTemperatureSensor);
lwM2MDevice = null;
fwLwM2MDevice = null;
swLwM2MDevice = null;
lwM2MBinaryAppDataContainer = null;
lwM2MTemperatureSensor = null;
}
private void destroySafe(Object obj) {
if (obj == null) return;
try {
Method destroy = obj.getClass().getMethod("destroy");
destroy.invoke(obj);
} catch (NoSuchMethodException e) {
// не має destroy() — ігноруємо
} catch (Exception e) {
log.warn("Failed to destroy {}", obj.getClass().getSimpleName(), e);
}
}
public void start(boolean isStartLw) {
if (leshanClient != null) {
leshanClient.start();
@ -483,4 +480,59 @@ public class LwM2MTestClient {
LwM2mClient lwM2MClient = this.clientContext.getClientByEndpoint(endpoint);
Mockito.doAnswer(invocationOnMock -> null).when(defaultLwM2mUplinkMsgHandlerTest).initAttributes(lwM2MClient, true);
}
private ObjectsInitializer createFreshInitializer() {
List<ObjectModel> models = new ArrayList<>(ObjectLoader.loadAllDefault());
for (String resourceName : lwm2mClientResources) {
try (InputStream in = LwM2MTestClient.class.getClassLoader()
.getResourceAsStream("lwm2m/" + resourceName)) {
models.addAll(ObjectLoader.loadDdfFile(in, resourceName));
} catch (IOException | InvalidDDFFileException e) {
log.warn("Failed to load resource {}", resourceName, e);
}
}
if (this.modelResources != null) {
List<ObjectModel> modelsRes = new ArrayList<>();
for (String resourceName : this.modelResources) {
try (InputStream in = LwM2MTestClient.class.getClassLoader()
.getResourceAsStream("lwm2m/" + resourceName)) {
modelsRes.addAll(ObjectLoader.loadDdfFile(in, resourceName));
} catch (IOException | InvalidDDFFileException e) {
log.warn("Failed to load resource {}", resourceName, e);
}
}
Set<Integer> idsToRemove = modelsRes.stream()
.map(m -> m.id)
.collect(Collectors.toSet());
models.removeIf(m -> idsToRemove.contains(m.id));
models.addAll(modelsRes);
}
LwM2mModel model = new StaticModel(models);
return new ObjectsInitializer(model);
}
private void forceNullSecurityId(Security security) {
if (security == null) {
return;
}
try {
Field field = security.getClass().getDeclaredField("id");
field.setAccessible(true);
field.set(security, null);
log.info("[forceNullSecurityId] Set id=null for {}", security);
} catch (NoSuchFieldException e) {
try {
//(SecurityObjectInstance)
Field field = security.getClass().getSuperclass().getDeclaredField("id");
field.setAccessible(true);
field.set(security, null);
log.info("[forceNullSecurityId] Set id=null for {} (via superclass)", security);
} catch (Exception ex) {
log.error("[forceNullSecurityId] Field 'id' not found for {}", security.getClass(), ex);
}
} catch (Exception e) {
log.error("[forceNullSecurityId] Failed to set id=null for {}", security.getClass(), e);
}
}
}

72
application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/AbstractRpcLwM2MIntegrationTest.java

@ -43,12 +43,11 @@ import static org.awaitility.Awaitility.await;
import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL;
import static org.eclipse.leshan.core.LwM2mId.DEVICE;
import static org.eclipse.leshan.core.LwM2mId.FIRMWARE;
import static org.eclipse.leshan.core.LwM2mId.SECURITY;
import static org.eclipse.leshan.core.LwM2mId.SERVER;
import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_0;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_1;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_1;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_12;
@ -115,6 +114,10 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg
initRpc(0);
} else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationReadCollectedValueTest")) {
initRpc(3303);
} else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationInitReadCompositeAllTest")) {
initRpc(2);
}else if (this.getClass().getSimpleName().equals("RpcLwm2mIntegrationInitReadCompositeByObjectTest")) {
initRpc(3);
} else {
initRpc(1);
}
@ -140,10 +143,10 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg
});
}
});
String ver_Id_0 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_0).version;
String ver_Id_1 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_1).version;
objectIdVer_0 = "/" + OBJECT_ID_0 + "_" + ver_Id_0;
objectIdVer_1 = "/" + OBJECT_ID_1 + "_" + ver_Id_1;
String ver_Id_0 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SECURITY).version;
String ver_Id_1 = lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SERVER).version;
objectIdVer_0 = "/" + SECURITY + "_" + ver_Id_0;
objectIdVer_1 = "/" + SERVER + "_" + ver_Id_1;
objectIdVer_2 = (String) expectedObjectIdVers.stream().filter(path -> ((String) path).startsWith("/" + ACCESS_CONTROL)).findFirst().get();
objectIdVer_3 = (String) expectedObjectIdVers.stream().filter(PREDICATE_3).findFirst().get();
objectIdVer_19 = (String) expectedObjectIdVers.stream().filter(path -> ((String) path).startsWith("/" + BINARY_APP_DATA_CONTAINER)).findFirst().get();
@ -221,10 +224,67 @@ public abstract class AbstractRpcLwM2MIntegrationTest extends AbstractLwM2MInteg
" ],\n" +
" \"attributeLwm2m\": {}\n" +
" }";
String INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_ALL =
" {\n" +
" \"keyName\": {\n" +
" \"/3_1.2/0/9\": \"batteryLevel\",\n" +
" \"/3_1.2/0/20\": \"batteryStatus\",\n" +
" \"/19_1.1/0/2\": \"dataCreationTime\",\n" +
" \"/5_1.2/0/6\": \"pkgname\",\n" +
" \"/5_1.2/0/7\": \"pkgversion\",\n" +
" \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\n" +
" },\n" +
" \"observe\": [\n" +
" \"/3_1.2/0/20\"\n" +
" ],\n" +
" \"attribute\": [\n" +
" \"/5_1.2/0/6\",\n" +
" \"/5_1.2/0/7\"\n" +
" ],\n" +
" \"telemetry\": [\n" +
" \"/3_1.2/0/9\",\n" +
" \"/3_1.2/0/20\",\n" +
" \"/5_1.2/0/9\",\n" +
" \"/19_1.1/0/2\"\n" +
" ],\n" +
" \"attributeLwm2m\": {},\n" +
" \"initAttrTelAsObsStrategy\": true,\n" +
" \"observeStrategy\": 1\n" +
" }";
String INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_BY_OBJECT =
" {\n" +
" \"keyName\": {\n" +
" \"/3_1.2/0/9\": \"batteryLevel\",\n" +
" \"/3_1.2/0/20\": \"batteryStatus\",\n" +
" \"/19_1.1/0/2\": \"dataCreationTime\",\n" +
" \"/5_1.2/0/6\": \"pkgname\",\n" +
" \"/5_1.2/0/7\": \"pkgversion\",\n" +
" \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\n" +
" },\n" +
" \"observe\": [\n" +
" \"/3_1.2/0/9\"\n" +
" ],\n" +
" \"attribute\": [\n" +
" \"/5_1.2/0/6\",\n" +
" \"/5_1.2/0/7\"\n" +
" ],\n" +
" \"telemetry\": [\n" +
" \"/3_1.2/0/9\",\n" +
" \"/3_1.2/0/20\",\n" +
" \"/5_1.2/0/9\",\n" +
" \"/19_1.1/0/2\"\n" +
" ],\n" +
" \"attributeLwm2m\": {},\n" +
" \"initAttrTelAsObsStrategy\": true,\n" +
" \"observeStrategy\": 2\n" +
" }";
CONFIG_PROFILE_WITH_PARAMS_RPC =
switch (typeConfigProfile) {
case 0 -> ATTRIBUTES_TELEMETRY_WITH_PARAMS_RPC_WITH_OBSERVE;
case 1 -> TELEMETRY_WITH_PARAMS_RPC_WITHOUT_OBSERVE;
case 2 -> INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_ALL;
case 3 -> INIT_READ_TELEMETRY_ATTRIBUTE_AS_OBSERVE_STRATEGY_BY_OBJECT;
case 3303 -> TELEMETRY_WITH_PARAMS_RPC_COLLECTED_VALUE;
default -> throw new IllegalStateException("Unexpected value: " + typeConfigProfile);
};

92
application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeAllTest.java

@ -0,0 +1,92 @@
/**
* 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.rpc.sql;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_6;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_7;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9;
@Slf4j
public class RpcLwm2mIntegrationInitReadCompositeAllTest extends AbstractRpcLwM2MIntegrationTest {
/**
" \"/3_1.2/0/9\": \"batteryLevel\", - Telemetry
" \"/3_1.2/0/20\": \"batteryStatus\" - Observe, Telemetry
" \"/5_1.2/0/6\": \"pkgname\" - Attributes
" \"/5_1.2/0/7\": \"pkgversion\" - Attributes
" \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\ - Telemetry
" \"/19_1.1/0/2\": \"dataCreationTime\" - Telemetry
* "observeStrategy": 1
*/
@Test
public void testInitReadCompositeAsObserveStrategyCompositeAll() throws Exception {
// init test
String RESOURCE_3_9 = "batteryLevel";
String RESOURCE_3_20 = "batteryStatus";
String RESOURCE_5_6 = "pkgname";
String RESOURCE_5_7 = "pkgversion";
String RESOURCE_5_9 = "firmwareUpdateDeliveryMethod";
String RESOURCE_19_2 = "dataCreationTime";
String idVwr_3_0_20 = idVer_3_0_9 = objectIdVer_3 + "/" + OBJECT_INSTANCE_ID_0 + "/" + 20;
String IdVer5_0_6 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_6;
String IdVer5_0_7 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_7;
String IdVer5_0_9 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_9;
String idVer_19_0_2 = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_2;
countUpdateAttrTelemetryResource(idVer_3_0_9);
countUpdateAttrTelemetryResource(idVwr_3_0_20);
countUpdateAttrTelemetryResource(IdVer5_0_6);
countUpdateAttrTelemetryResource(IdVer5_0_7);
countUpdateAttrTelemetryResource(IdVer5_0_9);
countUpdateAttrTelemetryResource(idVer_19_0_2);
AtomicReference<ObjectNode> actualValues = new AtomicReference<>();
await().atMost(40, SECONDS).until(() -> {
actualValues.set(doGetAsync(
"/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/values/timeseries?keys="
+ RESOURCE_3_9 + "," + RESOURCE_3_20 + "," + RESOURCE_5_9 + "," + RESOURCE_19_2, ObjectNode.class));
return actualValues.get() != null && !actualValues.get().isEmpty()
&& !actualValues.get().get(RESOURCE_3_9).isEmpty()
&& !actualValues.get().get(RESOURCE_3_20).isEmpty()
&& !actualValues.get().get(RESOURCE_5_9).isEmpty()
&& !actualValues.get().get(RESOURCE_19_2).isEmpty();
});
AtomicReference<List<String>> actualKeys =new AtomicReference<>();
await().atMost(40, SECONDS).until(() -> {
actualKeys.set(doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() {
}));
return actualKeys.get() != null && !actualKeys.get().isEmpty() && !actualKeys.get().isEmpty()
&& actualKeys.get().contains(RESOURCE_5_6)&& actualKeys.get().contains(RESOURCE_5_7);
});
}
}

92
application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationInitReadCompositeByObjectTest.java

@ -0,0 +1,92 @@
/**
* 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.rpc.sql;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.awaitility.Awaitility.await;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_2;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_6;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_7;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9;
@Slf4j
public class RpcLwm2mIntegrationInitReadCompositeByObjectTest extends AbstractRpcLwM2MIntegrationTest {
/**
" \"/3_1.2/0/9\": \"batteryLevel\", - Telemetry
" \"/3_1.2/0/20\": \"batteryStatus\" - Observe, Telemetry
" \"/5_1.2/0/6\": \"pkgname\" - Attributes
" \"/5_1.2/0/7\": \"pkgversion\" - Attributes
" \"/5_1.2/0/9\": \"firmwareUpdateDeliveryMethod\"\ - Telemetry
" \"/19_1.1/0/2\": \"dataCreationTime\" - Telemetry
* "observeStrategy": 2
*/
@Test
public void testInitReadCompositeAsObserveStrategyCompositeByObject() throws Exception {
// init test
String RESOURCE_3_9 = "batteryLevel";
String RESOURCE_3_20 = "batteryStatus";
String RESOURCE_5_6 = "pkgname";
String RESOURCE_5_7 = "pkgversion";
String RESOURCE_5_9 = "firmwareUpdateDeliveryMethod";
String RESOURCE_19_2 = "dataCreationTime";
String idVwr_3_0_20 = idVer_3_0_9 = objectIdVer_3 + "/" + OBJECT_INSTANCE_ID_0 + "/" + 20;
String IdVer5_0_6 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_6;
String IdVer5_0_7 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_7;
String IdVer5_0_9 = objectInstanceIdVer_5 + "/" + RESOURCE_ID_9;
String idVer_19_0_2 = objectIdVer_19 + "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_2;
countUpdateAttrTelemetryResource(idVer_3_0_9);
countUpdateAttrTelemetryResource(idVwr_3_0_20);
countUpdateAttrTelemetryResource(IdVer5_0_6);
countUpdateAttrTelemetryResource(IdVer5_0_7);
countUpdateAttrTelemetryResource(IdVer5_0_9);
countUpdateAttrTelemetryResource(idVer_19_0_2);
AtomicReference<ObjectNode> actualValues = new AtomicReference<>();
await().atMost(40, SECONDS).until(() -> {
actualValues.set(doGetAsync(
"/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/values/timeseries?keys="
+ RESOURCE_3_9 + "," + RESOURCE_3_20 + "," + RESOURCE_5_9 + "," + RESOURCE_19_2, ObjectNode.class));
return actualValues.get() != null && !actualValues.get().isEmpty()
&& !actualValues.get().get(RESOURCE_3_9).isEmpty()
&& !actualValues.get().get(RESOURCE_3_20).isEmpty()
&& !actualValues.get().get(RESOURCE_5_9).isEmpty()
&& !actualValues.get().get(RESOURCE_19_2).isEmpty();
});
AtomicReference<List<String>> actualKeys =new AtomicReference<>();
await().atMost(40, SECONDS).until(() -> {
actualKeys.set(doGetAsyncTyped("/api/plugins/telemetry/DEVICE/" + lwM2MTestClient.getDeviceIdStr() + "/keys/attributes/CLIENT_SCOPE", new TypeReference<>() {
}));
return actualKeys.get() != null && !actualKeys.get().isEmpty() && !actualKeys.get().isEmpty()
&& actualKeys.get().contains(RESOURCE_5_6)&& actualKeys.get().contains(RESOURCE_5_7);
});
}
}

32
application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadTest.java

@ -21,8 +21,10 @@ import org.eclipse.leshan.core.ResponseCode;
import org.eclipse.leshan.core.node.LwM2mPath;
import org.junit.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest;
import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ -53,18 +55,18 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest
try {
expectedObjectIdVers.forEach(expected -> {
try {
String actualResult = sendRPCById((String) expected);
String expectedObjectId = pathIdVerToObjectId((String) expected);
LwM2mPath expectedPath = new LwM2mPath(expectedObjectId);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText());
String expectedObjectInstances = "LwM2mObject [id=" + expectedPath.getObjectId() + ", instances={0=LwM2mObjectInstance [id=0, resources=";
if (expectedPath.getObjectId() == 1) {
expectedObjectInstances = "LwM2mObject [id=1, instances={1=";
} else if (expectedPath.getObjectId() == 2) {
expectedObjectInstances = "LwM2mObject [id=2, instances={}]";
if (expectedPath.getObjectId() > ACCESS_CONTROL) {
String actualResult = sendRPCByIdSync((String) expected);
if (StringUtils.isNoneBlank(actualResult)) {
log.warn(" expectedPath: [{}]", expectedPath);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText());
String expectedObjectInstances = "LwM2mObject [id=" + expectedPath.getObjectId() + ", instances={0=LwM2mObjectInstance [id=0, resources=";
assertTrue(rpcActualResult.get("value").asText().contains(expectedObjectInstances));
}
}
assertTrue(rpcActualResult.get("value").asText().contains(expectedObjectInstances));
} catch (Exception e) {
e.printStackTrace();
}
@ -83,7 +85,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest
public void testReadAllInstancesInClientById_Result_CONTENT_Value_IsInstances_IsResources() throws Exception {
expectedObjectIdVerInstances.forEach(expected -> {
try {
String actualResult = sendRPCById((String) expected);
String actualResult = sendRPCByIdAsync((String) expected);
String expectedObjectId = pathIdVerToObjectId((String) expected);
LwM2mPath expectedPath = new LwM2mPath(expectedObjectId);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@ -104,7 +106,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest
@Test
public void testReadMultipleResourceById_Result_CONTENT_Value_IsLwM2mMultipleResource() throws Exception {
String expectedIdVer = objectInstanceIdVer_3 + "/" + RESOURCE_ID_11;
String actualResult = sendRPCById(expectedIdVer);
String actualResult = sendRPCByIdAsync(expectedIdVer);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText());
String expected = "LwM2mMultipleResource [id=" + RESOURCE_ID_11 + ", values={";
@ -117,7 +119,7 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest
@Test
public void testReadSingleResourceById_Result_CONTENT_Value_IsLwM2mSingleResource() throws Exception {
String expectedIdVer = objectInstanceIdVer_3 + "/" + RESOURCE_ID_14;
String actualResult = sendRPCById(expectedIdVer);
String actualResult = sendRPCByIdAsync(expectedIdVer);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
assertEquals(ResponseCode.CONTENT.getName(), rpcActualResult.get("result").asText());
String expected = "LwM2mSingleResource [id=" + RESOURCE_ID_14 + ", value=";
@ -228,10 +230,14 @@ public class RpcLwm2mIntegrationReadTest extends AbstractRpcLwM2MIntegrationTest
assertEquals(actualValue, expectedValue);
}
private String sendRPCById(String path) throws Exception {
private String sendRPCByIdAsync(String path) throws Exception {
String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + path + "\"}}";
return doPostAsync("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk());
}
private String sendRPCByIdSync(String path) throws Exception {
String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"id\": \"" + path + "\"}}";
return doPost("/api/plugins/rpc/twoway/" + lwM2MTestClient.getDeviceIdStr(), setRpcRequest, String.class, status().isOk());
}
private String sendRPCByKey(String key) throws Exception {
String setRpcRequest = "{\"method\": \"Read\", \"params\": {\"key\": \"" + key + "\"}}";

37
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java

@ -69,6 +69,7 @@ import java.util.concurrent.TimeUnit;
import static org.awaitility.Awaitility.await;
import static org.eclipse.leshan.client.object.Security.noSecBootstrap;
import static org.eclipse.leshan.client.object.Security.psk;
import static org.eclipse.leshan.core.LwM2mId.SERVER;
import static org.junit.Assert.assertEquals;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK;
@ -77,7 +78,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClient
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_STARTED;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_UPDATE_SUCCESS;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_ID_1;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INSTANCE_ID_0;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID_9;
@DaoSqlTest
@ -95,7 +96,6 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M
protected static final String SERVER_STORE_PWD = "server_ks_password";
protected static final String SERVER_CERT_ALIAS = "server";
protected static final String SERVER_CERT_ALIAS_BS = "bootstrap";
protected static final Security SECURITY_NO_SEC_BS = noSecBootstrap(URI_BS);;
protected final X509Certificate serverX509Cert; // server certificate signed by rootCA
protected final X509Certificate serverX509CertBs; // serverBs certificate signed by rootCA
protected final PublicKey serverPublicKeyFromCert; // server public key used for RPK
@ -178,20 +178,20 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M
defaultBootstrapCredentials.setLwm2mServer(serverCredentials);
}
public void basicTestConnectionBefore(String clientEndpoint,
String awaitAlias,
LwM2MProfileBootstrapConfigType type,
Set<LwM2MClientState> expectedStatuses,
LwM2MClientState finishState) throws Exception {
public void basicTestConnectionStartBS(String clientEndpoint,
String awaitAlias,
LwM2MProfileBootstrapConfigType type,
Set<LwM2MClientState> expectedStatuses,
LwM2MClientState finishState) throws Exception {
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsNoSec(type));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint));
this.basicTestConnection(null , SECURITY_NO_SEC_BS,
this.basicTestConnection(null , noSecBootstrap(URI_BS),
deviceCredentials,
clientEndpoint,
transportConfiguration,
awaitAlias,
expectedStatuses,
true,
false,
finishState,
false);
}
@ -231,12 +231,19 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M
}
public void basicTestConnectionBootstrapRequestTriggerBefore(String clientEndpoint, String awaitAlias, LwM2MProfileBootstrapConfigType type) throws Exception {
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsNoSec(type));
public void basicTestConnectionBootstrapRequestTriggerBefore(String clientEndpoint, String awaitAlias, LwM2MProfileBootstrapConfigType type, int cnt) throws Exception {
List<LwM2MBootstrapServerCredential> bootstrapServerCredentialsNoSec = getBootstrapServerCredentialsNoSec(type);
for (int i = 2; i <= cnt; i++) {
AbstractLwM2MBootstrapServerCredential bsCredential = getBootstrapServerCredentialNoSec(false);
bsCredential.setHost("0.0.0." + i);
bsCredential.setShortServerId(bsCredential.getShortServerId() + i);
bootstrapServerCredentialsNoSec.add(bsCredential);
}
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, bootstrapServerCredentialsNoSec);
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint));
this.basicTestConnectionBootstrapRequestTrigger(
SECURITY_NO_SEC,
SECURITY_NO_SEC_BS,
noSecBootstrap(URI_BS),
deviceCredentials,
clientEndpoint,
transportConfiguration,
@ -275,8 +282,8 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M
});
Assert.assertTrue(lwM2MTestClient.getClientStates().containsAll(expectedStatusesLwm2m));
String executedPath = "/" + OBJECT_ID_1 + "_" + lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(OBJECT_ID_1).version
+ "/" +serverId + "/" + RESOURCE_ID_9;
String executedPath = "/" + SERVER + "_" + lwM2MTestClient.getLeshanClient().getObjectTree().getModel().getObjectModel(SERVER).version
+ "/" + OBJECT_INSTANCE_ID_0 + "/" + RESOURCE_ID_9;
lwM2MTestClient.setClientStates(new HashSet<>());
String actualResult = sendRPCSecurityExecuteById(executedPath, deviceIdStr, endpoint);
ObjectNode rpcActualResult = JacksonUtil.fromString(actualResult, ObjectNode.class);
@ -352,7 +359,7 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M
default:
throw new IllegalStateException("Unexpected value: " + mode);
}
bootstrapServerCredential.setShortServerId(isBootstrap ? shortServerIdBs0 : shortServerId);
bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId);
bootstrapServerCredential.setBootstrapServerIs(isBootstrap);
bootstrapServerCredential.setHost(isBootstrap ? hostBs : host);
bootstrapServerCredential.setPort(isBootstrap ? securityPortBs : securityPort);

29
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBS3SectionTriggerTest.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.transport.lwm2m.security.sql;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY;
public class NoSecLwM2MIntegrationBS3SectionTriggerTest extends AbstractSecurityLwM2MIntegrationTest {
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTrigger_3_ConnectBsSuccess_UpdateLwm2mSection_3_AndLm2m_1_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger_3" + LWM2M_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, 3);
}
}

37
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest.java

@ -0,0 +1,37 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.transport.lwm2m.security.sql;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class NoSecLwM2MIntegrationBSLwm2mOnlyNoneTriggerOneSectionTest extends AbstractSecurityLwM2MIntegrationTest {
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + LWM2M_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, 1);
}
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateNoneSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + NONE.name();
String awaitAlias = "await on client state (NoSecBS Trigger None section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, NONE, 1);
}
}

39
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSNoTriggerTest.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.sql;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY;
public class NoSecLwM2MIntegrationBSNoTriggerTest extends AbstractSecurityLwM2MIntegrationTest {
@Test
public void testWithNoSecConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "NoTrigger" + BOTH.name();
String awaitAlias = "await on client state (NoSecBS two section)";
basicTestConnectionStartBS(clientEndpoint, awaitAlias, BOTH, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS);
}
@Test
public void testWithNoSecConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "NoTrigger" + LWM2M_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Lwm2m section)";
basicTestConnectionStartBS(clientEndpoint, awaitAlias, LWM2M_ONLY, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS);
}
}

29
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest.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.transport.lwm2m.security.sql;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOOTSTRAP_ONLY;
public class NoSecLwM2MIntegrationBSOnlyTriggerOneSectionTest extends AbstractSecurityLwM2MIntegrationTest {
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateBootstrapSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOOTSTRAP_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Trigger Bootstrap section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOOTSTRAP_ONLY, 1);
}
}

31
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationBSTriggerTest.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.transport.lwm2m.security.sql;
import org.junit.Test;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH;
public class NoSecLwM2MIntegrationBSTriggerTest extends AbstractSecurityLwM2MIntegrationTest {
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateTwoSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOTH.name();
String awaitAlias = "await on client state (NoSecBS Trigger Two section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOTH, 1);
}
}

50
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/NoSecLwM2MIntegrationTest.java

@ -19,12 +19,6 @@ import org.junit.Test;
import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials;
import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState.ON_REGISTRATION_SUCCESS;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOOTSTRAP_ONLY;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.BOTH;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.LWM2M_ONLY;
import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE;
public class NoSecLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationTest {
//Lwm2m only
@ -34,48 +28,4 @@ public class NoSecLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationT
LwM2MDeviceCredentials clientCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint));
super.basicTestConnectionObserveSingleTelemetry(SECURITY_NO_SEC, clientCredentials, clientEndpoint, false, false);
}
// Bootstrap + Lwm2m
@Test
public void testWithNoSecConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + BOTH.name();
String awaitAlias = "await on client state (NoSecBS two section)";
basicTestConnectionBefore(clientEndpoint, awaitAlias, BOTH, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS);
}
@Test
public void testWithNoSecConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + LWM2M_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Lwm2m section)";
basicTestConnectionBefore(clientEndpoint, awaitAlias, LWM2M_ONLY, expectedStatusesRegistrationBsSuccess, ON_REGISTRATION_SUCCESS);
}
// Bs trigger
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateTwoSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOTH.name();
String awaitAlias = "await on client state (NoSecBS Trigger Two section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOTH);
}
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateBootstrapSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + BOOTSTRAP_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Trigger Bootstrap section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, BOOTSTRAP_ONLY);
}
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateLwm2mSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + LWM2M_ONLY.name();
String awaitAlias = "await on client state (NoSecBS Trigger Lwm2m section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, LWM2M_ONLY);
}
@Test
public void testWithNoSecConnectLwm2mSuccessBootstrapRequestTriggerConnectBsSuccess_UpdateNoneSectionAndLm2m_ConnectLwm2mSuccess() throws Exception {
String clientEndpoint = CLIENT_ENDPOINT_NO_SEC_BS + "Trigger" + NONE.name();
String awaitAlias = "await on client state (NoSecBS Trigger None section)";
basicTestConnectionBootstrapRequestTriggerBefore(clientEndpoint, awaitAlias, NONE);
}
}

13
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java

@ -58,8 +58,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
Hex.decodeHex(keyPsk.toCharArray()));
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false);
this.basicTestConnection(security,
null,
this.basicTestConnection(security, null,
deviceCredentials,
clientEndpoint,
transportConfiguration,
@ -85,8 +84,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsSecure(PSK, NONE));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false);
String awaitAlias = "await on client state (Psk_Lwm2m)";
Device lwm2mDevice = this.basicTestConnection(security,
null,
Device lwm2mDevice = this.basicTestConnection(security, null,
deviceCredentials,
clientEndpoint,
transportConfiguration,
@ -121,8 +119,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsSecure(PSK, NONE));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false);
String awaitAlias = "await on client state (Psk_Lwm2m)";
Device lwm2mDevice = this.basicTestConnection(security,
null,
Device lwm2mDevice = this.basicTestConnection(security, null,
deviceCredentials,
clientEndpoint,
transportConfiguration,
@ -175,12 +172,12 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
clientCredentials.setEndpoint(clientEndpoint);
clientCredentials.setIdentity(identity);
clientCredentials.setKey(keyPsk);
Security securityBs = pskBootstrap(SECURE_URI_BS,
Security securityPskBs = pskBootstrap(SECURE_URI_BS,
identity.getBytes(StandardCharsets.UTF_8),
Hex.decodeHex(keyPsk.toCharArray()));
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, BOTH));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false);
this.basicTestConnection(null, securityBs,
this.basicTestConnection(null, securityPskBs,
deviceCredentials,
clientEndpoint,
transportConfiguration,

4
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/RpkLwM2MIntegrationTest.java

@ -113,13 +113,13 @@ public class RpkLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegrationTes
RPKClientCredential clientCredentials = new RPKClientCredential();
clientCredentials.setEndpoint(clientEndpoint);
clientCredentials.setKey(Base64.encodeBase64String(certificate.getPublicKey().getEncoded()));
Security securityBs = rpkBootstrap(SECURE_URI_BS,
Security securityRpkBs = rpkBootstrap(SECURE_URI_BS,
certificate.getPublicKey().getEncoded(),
privateKey.getEncoded(),
serverX509CertBs.getPublicKey().getEncoded());
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(RPK, BOTH));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, clientPrivateKeyFromCertTrust, certificate, RPK, false);
this.basicTestConnection(null, securityBs,
this.basicTestConnection(null, securityRpkBs,
deviceCredentials,
clientEndpoint,
transportConfiguration,

8
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_NoTrustLwM2MIntegrationTest.java

@ -51,15 +51,14 @@ public class X509_NoTrustLwM2MIntegrationTest extends AbstractSecurityLwM2MInteg
X509ClientCredential clientCredentials = new X509ClientCredential();
clientCredentials.setEndpoint(clientEndpoint);
clientCredentials.setCert(Base64.getEncoder().encodeToString(certificate.getEncoded()));
Security security = x509(SECURE_URI,
Security securityX509 = x509(SECURE_URI,
shortServerId,
certificate.getEncoded(),
privateKey.getEncoded(),
serverX509Cert.getEncoded());
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, NONE));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false);
this.basicTestConnection(security,
null,
this.basicTestConnection(securityX509, null,
deviceCredentials,
clientEndpoint,
transportConfiguration,
@ -119,8 +118,7 @@ public class X509_NoTrustLwM2MIntegrationTest extends AbstractSecurityLwM2MInteg
serverX509CertBs.getEncoded());
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, BOTH));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false);
this.basicTestConnection(security,
null,
this.basicTestConnection(security, null,
deviceCredentials,
clientEndpoint,
transportConfiguration,

6
application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/X509_TrustLwM2MIntegrationTest.java

@ -50,8 +50,7 @@ public class X509_TrustLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegra
serverX509Cert.getEncoded());
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, NONE));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false);
this.basicTestConnection(security,
null,
this.basicTestConnection(security, null,
deviceCredentials,
clientEndpoint,
transportConfiguration,
@ -77,8 +76,7 @@ public class X509_TrustLwM2MIntegrationTest extends AbstractSecurityLwM2MIntegra
serverX509CertBs.getEncoded());
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(X509, BOTH));
LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, privateKey, certificate, X509, false);
this.basicTestConnection(security,
null,
this.basicTestConnection(security,null,
deviceCredentials,
clientEndpoint,
transportConfiguration,

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

@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.ToDeviceActorNotificationMsg;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
@ -137,4 +138,8 @@ public interface TbClusterService extends TbQueueClusterService {
void onCalculatedFieldDeleted(CalculatedField calculatedField, TbQueueCallback callback);
void onRelationUpdated(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback);
void onRelationDeleted(TenantId tenantId, EntityRelation entityRelation, TbQueueCallback callback);
}

2
common/dao-api/src/main/java/org/thingsboard/server/dao/relation/RelationService.java

@ -86,6 +86,8 @@ public interface RelationService {
ListenableFuture<List<EntityRelation>> findByRelationPathQueryAsync(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
// TODO: This method may be useful for some validations in the future
// ListenableFuture<Boolean> checkRecursiveRelation(EntityId from, EntityId to);

1
common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.dao.tenant;
import org.thingsboard.server.common.data.SystemParams;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;

6
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -23,6 +23,8 @@ import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.id.UserCredentialsId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.UserCredentials;
@ -109,4 +111,8 @@ public interface UserService extends EntityDaoService {
void removeMobileSession(TenantId tenantId, String mobileToken);
PageData<User> findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink);
boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user);
}

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

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

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

@ -25,7 +25,8 @@ public enum CalculatedFieldType {
SCRIPT,
GEOFENCING,
ALARM,
PROPAGATION;
PROPAGATION,
RELATED_ENTITIES_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/Argument.java

@ -45,4 +45,8 @@ public class Argument {
return hasDynamicSource() && refDynamicSourceConfiguration.getType() == CFArgumentDynamicSourceType.CURRENT_OWNER;
}
public boolean hasTsRollingArgument() {
return ArgumentType.TS_ROLLING.equals(refEntityKey.getType());
}
}

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.RelatedEntitiesAggregationCalculatedFieldConfiguration;
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;
@ -40,7 +41,8 @@ import java.util.stream.Collectors;
@Type(value = ScriptCalculatedFieldConfiguration.class, name = "SCRIPT"),
@Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION")
@Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION"),
@Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface CalculatedFieldConfiguration {

20
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggFunction.java

@ -0,0 +1,20 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation;
public enum AggFunction {
MIN, MAX, SUM, AVG, COUNT, COUNT_UNIQUE
}

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

@ -0,0 +1,34 @@
/**
* 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;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AggFunctionInput implements AggInput {
private String function;
@Override
public String getType() {
return "function";
}
}

38
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java

@ -0,0 +1,38 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration.aggregation;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = AggKeyInput.class, name = "key"),
@JsonSubTypes.Type(value = AggFunctionInput.class, name = "function")
})
@JsonIgnoreProperties(ignoreUnknown = true)
public interface AggInput {
@JsonIgnore
String getType();
}

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

@ -0,0 +1,34 @@
/**
* 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;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AggKeyInput implements AggInput {
private String key;
@Override
public String getType() {
return "key";
}
}

31
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.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;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public class AggMetric {
private AggFunction function;
private String filter;
private AggInput input;
}

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

Loading…
Cancel
Save