Browse Source

Merge branch 'feature/entity-alarm-rules' of github.com:thingsboard/thingsboard into feature/aggregation-cf

pull/14141/head
IrynaMatveieva 8 months ago
parent
commit
aee50f94f3
  1. 8
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 13
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
  3. 16
      application/src/main/java/org/thingsboard/server/controller/AssetController.java
  4. 14
      application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java
  5. 16
      application/src/main/java/org/thingsboard/server/controller/CustomerController.java
  6. 27
      application/src/main/java/org/thingsboard/server/controller/DeviceController.java
  7. 16
      application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
  8. 5
      application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java
  9. 36
      application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java
  10. 41
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
  11. 49
      application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java
  12. 6
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
  13. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
  14. 18
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java
  15. 50
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
  16. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
  17. 2
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
  18. 11
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java
  19. 6
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java
  20. 4
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingArgumentEntry.java
  21. 12
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/geofencing/GeofencingCalculatedFieldState.java
  22. 72
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java
  23. 113
      application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java
  24. 7
      application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
  25. 4
      application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java
  26. 16
      application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java
  27. 6
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
  28. 2
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java
  29. 130
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java
  30. 28
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.java
  31. 81
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java
  32. 8
      application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java
  33. 3
      application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java
  34. 8
      application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java
  35. 4
      application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java
  36. 15
      application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java
  37. 5
      application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java
  38. 8
      application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java
  39. 3
      application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java
  40. 9
      application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java
  41. 2
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java
  42. 20
      application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java
  43. 19
      application/src/main/java/org/thingsboard/server/utils/CsvUtils.java
  44. 47
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  45. 166
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  46. 24
      application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java
  47. 97
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  48. 22
      application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
  49. 81
      application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
  50. 25
      application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java
  51. 196
      application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java
  52. 6
      application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java
  53. 143
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java
  54. 247
      application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java
  55. 91
      application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java
  56. 47
      application/src/test/java/org/thingsboard/server/utils/CalculatedFieldUtilsTest.java
  57. 2
      application/src/test/resources/logback-test.xml
  58. 4
      common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java
  59. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java
  60. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
  61. 5
      common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java
  62. 3
      common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
  63. 23
      common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java
  64. 25
      common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java
  65. 23
      common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java
  66. 1
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java
  67. 2
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java
  68. 7
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java
  69. 10
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java
  70. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java
  71. 96
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java
  72. 3
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java
  73. 13
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinates.java
  74. 16
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java
  75. 10
      common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfiguration.java
  76. 3
      common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java
  77. 1
      common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java
  78. 4
      common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java
  79. 12
      common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java
  80. 153
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java
  81. 2
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java
  82. 31
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java
  83. 29
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java
  84. 18
      common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/ZoneGroupConfigurationTest.java
  85. 2
      common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java
  86. 9
      common/edge-api/src/main/proto/edge.proto
  87. 3
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfArg.java
  88. 4
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java
  89. 42
      common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java
  90. 5
      dao/src/main/java/org/thingsboard/server/dao/Dao.java
  91. 41
      dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java
  92. 20
      dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java
  93. 3
      dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java
  94. 27
      dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java
  95. 33
      dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java
  96. 44
      dao/src/main/java/org/thingsboard/server/dao/entity/AbstractEntityService.java
  97. 20
      dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java
  98. 22
      dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java
  99. 2
      dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java
  100. 5
      dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java

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

@ -35,6 +35,12 @@ SET profile_data = jsonb_set(
THEN NULL
ELSE to_jsonb(10)
END,
'maxRelatedEntitiesToReturnPerCfArgument',
CASE
WHEN (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
THEN NULL
ELSE to_jsonb(100)
END,
'minAllowedDeduplicationIntervalInSecForCF',
CASE
WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
@ -50,6 +56,8 @@ WHERE NOT (
AND
(profile_data -> 'configuration') ? 'maxRelationLevelPerCfArgument'
AND
(profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument'
AND
(profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF'
);

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

@ -423,13 +423,15 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
if (state == null) {
state = createState(ctx);
justRestored = true;
} else if (ctx.shouldFetchDynamicArgumentsFromDb(state)) {
} else if (ctx.shouldFetchRelationQueryDynamicArgumentsFromDb(state)) {
log.debug("[{}][{}] Going to update dynamic arguments for CF.", entityId, ctx.getCfId());
try {
Map<String, ArgumentEntry> dynamicArgsFromDb = cfService.fetchDynamicArgsFromDb(ctx, entityId);
dynamicArgsFromDb.forEach(newArgValues::putIfAbsent);
var geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING) {
var geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.updateLastDynamicArgumentsRefreshTs();
}
} catch (Exception e) {
throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build();
}
@ -457,9 +459,10 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) {
state.setCtx(ctx, actorCtx);
state.init();
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.hasRelationQueryDynamicArguments()) {
if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) {
GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state;
geofencingState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis());
geofencingState.updateLastDynamicArgumentsRefreshTs();
}
Map<String, ArgumentEntry> arguments = fetchArguments(ctx);

16
application/src/main/java/org/thingsboard/server/controller/AssetController.java

@ -34,6 +34,9 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetInfo;
import org.thingsboard.server.common.data.asset.AssetSearchQuery;
@ -76,6 +79,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_
import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@ -83,6 +88,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
import static org.thingsboard.server.controller.EdgeController.EDGE_ID;
@ -137,10 +143,16 @@ public class AssetController extends BaseController {
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/asset", method = RequestMethod.POST)
@ResponseBody
public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset) throws Exception {
public Asset saveAsset(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the asset.") @RequestBody Asset asset,
@Parameter(description = NAME_CONFLICT_POLICY_DESC)
@RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
@Parameter(description = UNIQUIFY_SEPARATOR_DESC)
@RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
@Parameter(description = UNIQUIFY_STRATEGY_DESC)
@RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
asset.setTenantId(getTenantId());
checkEntity(asset.getId(), asset, Resource.ASSET);
return tbAssetService.save(asset, getCurrentUser());
return tbAssetService.save(asset, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete asset (deleteAsset)",

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

@ -1744,4 +1744,18 @@ public class ControllerConstants {
MARKDOWN_CODE_BLOCK_END ;
protected static final String SECURITY_WRITE_CHECK = " Security check is performed to verify that the user has 'WRITE' permission for the entity (entities).";
public static final String NAME_CONFLICT_POLICY_DESC = "Optional value of name conflict policy. Possible values: FAIL or UNIQUIFY. " +
" If omitted, FAIL policy is applied. FAIL policy implies exception will be thrown if an entity with the same name already exists. " +
" UNIQUIFY policy appends a suffix to the entity name, if a name conflict occurs.";
public static final String UNIQUIFY_SEPARATOR_DESC = "Optional value of name suffix separator used by UNIQUIFY policy. By default, underscore separator is used. " +
"For example, strategy is UNIQUIFY, separator is '-'; if a name conflict occurs for entity name 'test-name', " +
"created entity will have name like 'test-name-7fsh4f'.";
public static final String UNIQUIFY_STRATEGY_DESC = "Optional value of uniquify strategy used by UNIQUIFY policy. Possible values: RANDOM or INCREMENTAL. " +
"By default, RANDOM strategy is used, which means random alphanumeric string will be added as a suffix to entity name. " +
"INCREMENTAL implies the first possible number starting from 1 will be added as a name suffix. " +
"For example, strategy is UNIQUIFY, uniquify strategy is INCREMENTAL; if a name conflict occurs for entity name 'test-name', " +
"created entity will have name like 'test-name-1.";
}

16
application/src/main/java/org/thingsboard/server/controller/CustomerController.java

@ -32,6 +32,9 @@ import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
@ -47,6 +50,8 @@ import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID;
import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.CUSTOMER_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.HOME_DASHBOARD;
import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@ -54,6 +59,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
@RestController
@ -128,10 +134,16 @@ public class CustomerController extends BaseController {
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
@RequestMapping(value = "/customer", method = RequestMethod.POST)
@ResponseBody
public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer) throws Exception {
public Customer saveCustomer(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the customer.") @RequestBody Customer customer,
@Parameter(description = NAME_CONFLICT_POLICY_DESC)
@RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
@Parameter(description = UNIQUIFY_SEPARATOR_DESC)
@RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
@Parameter(description = UNIQUIFY_STRATEGY_DESC)
@RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
customer.setTenantId(getTenantId());
checkEntity(customer.getId(), customer, Resource.CUSTOMER);
return tbCustomerService.save(customer, getCurrentUser());
return tbCustomerService.save(customer, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete Customer (deleteCustomer)",

27
application/src/main/java/org/thingsboard/server/controller/DeviceController.java

@ -46,8 +46,11 @@ import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceInfo;
import org.thingsboard.server.common.data.DeviceInfoFilter;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.device.DeviceSearchQuery;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
@ -108,6 +111,8 @@ import static org.thingsboard.server.controller.ControllerConstants.EDGE_ASSIGN_
import static org.thingsboard.server.controller.ControllerConstants.EDGE_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_ASYNC_FIRST_STEP_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@ -117,6 +122,7 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHO
import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_ID_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK;
import static org.thingsboard.server.controller.EdgeController.EDGE_ID;
@ -177,14 +183,21 @@ public class DeviceController extends BaseController {
@ResponseBody
public Device saveDevice(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the device.") @RequestBody Device device,
@Parameter(description = "Optional value of the device credentials to be used during device creation. " +
"If omitted, access token will be auto-generated.") @RequestParam(name = "accessToken", required = false) String accessToken) throws Exception {
"If omitted, access token will be auto-generated.")
@RequestParam(name = "accessToken", required = false) String accessToken,
@Parameter(description = NAME_CONFLICT_POLICY_DESC)
@RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
@Parameter(description = UNIQUIFY_SEPARATOR_DESC)
@RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
@Parameter(description = UNIQUIFY_STRATEGY_DESC)
@RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
device.setTenantId(getCurrentUser().getTenantId());
if (device.getId() != null) {
checkDeviceId(device.getId(), Operation.WRITE);
} else {
checkEntity(null, device, Resource.DEVICE);
}
return tbDeviceService.save(device, accessToken, getCurrentUser());
return tbDeviceService.save(device, accessToken, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Create Device (saveDevice) with credentials ",
@ -209,12 +222,18 @@ public class DeviceController extends BaseController {
@RequestMapping(value = "/device-with-credentials", method = RequestMethod.POST)
@ResponseBody
public Device saveDeviceWithCredentials(@Parameter(description = "The JSON object with device and credentials. See method description above for example.")
@Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials) throws ThingsboardException {
@Valid @RequestBody SaveDeviceWithCredentialsRequest deviceAndCredentials,
@Parameter(description = NAME_CONFLICT_POLICY_DESC)
@RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
@Parameter(description = UNIQUIFY_SEPARATOR_DESC)
@RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
@Parameter(description = UNIQUIFY_STRATEGY_DESC)
@RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws ThingsboardException {
Device device = deviceAndCredentials.getDevice();
DeviceCredentials credentials = deviceAndCredentials.getCredentials();
device.setTenantId(getCurrentUser().getTenantId());
checkEntity(device.getId(), device, Resource.DEVICE);
return tbDeviceService.saveDeviceWithCredentials(device, credentials, getCurrentUser());
return tbDeviceService.saveDeviceWithCredentials(device, credentials, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete device (deleteDevice)",

16
application/src/main/java/org/thingsboard/server/controller/EntityViewController.java

@ -34,6 +34,9 @@ import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.EntityViewInfo;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.UniquifyStrategy;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@ -69,6 +72,8 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE;
import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION;
@ -76,6 +81,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC;
import static org.thingsboard.server.controller.EdgeController.EDGE_ID;
/**
@ -128,7 +134,13 @@ public class EntityViewController extends BaseController {
@ResponseBody
public EntityView saveEntityView(
@Parameter(description = "A JSON object representing the entity view.")
@RequestBody EntityView entityView) throws Exception {
@RequestBody EntityView entityView,
@Parameter(description = NAME_CONFLICT_POLICY_DESC)
@RequestParam(name = "nameConflictPolicy", defaultValue = "FAIL") NameConflictPolicy nameConflictPolicy,
@Parameter(description = UNIQUIFY_SEPARATOR_DESC)
@RequestParam(name = "uniquifySeparator", defaultValue = "_") String uniquifySeparator,
@Parameter(description = UNIQUIFY_STRATEGY_DESC)
@RequestParam(name = "uniquifyStrategy", defaultValue = "RANDOM") UniquifyStrategy uniquifyStrategy) throws Exception {
entityView.setTenantId(getCurrentUser().getTenantId());
EntityView existingEntityView = null;
if (entityView.getId() == null) {
@ -137,7 +149,7 @@ public class EntityViewController extends BaseController {
} else {
existingEntityView = checkEntityViewId(entityView.getId(), Operation.WRITE);
}
return tbEntityViewService.save(entityView, existingEntityView, getCurrentUser());
return tbEntityViewService.save(entityView, existingEntityView, new NameConflictStrategy(nameConflictPolicy, uniquifySeparator, uniquifyStrategy), getCurrentUser());
}
@ApiOperation(value = "Delete entity view (deleteEntityView)",

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

@ -27,6 +27,8 @@ import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MServerSecurityConfigDefault;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@ -37,6 +39,7 @@ import org.thingsboard.server.service.lwm2m.LwM2MService;
import java.util.Map;
import static org.thingsboard.server.common.data.NameConflictStrategy.DEFAULT;
import static org.thingsboard.server.controller.ControllerConstants.IS_BOOTSTRAP_SERVER_PARAM_DESCRIPTION;
import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH;
@ -73,6 +76,6 @@ public class Lwm2mController extends BaseController {
public Device saveDeviceWithCredentials(@RequestBody Map<Class<?>, Object> deviceWithDeviceCredentials) throws ThingsboardException {
Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class));
DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class));
return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials));
return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy());
}
}

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

@ -58,6 +58,8 @@ import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.CalculatedFieldType.PROPAGATION;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry;
@ -93,24 +95,29 @@ public abstract class AbstractCalculatedFieldProcessingService {
protected abstract String getExecutorNamePrefix();
protected ListenableFuture<Map<String, ArgumentEntry>> fetchArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCalculatedField().getType()) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCfType()) {
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts);
case SIMPLE, SCRIPT, ALARM -> {
Map<String, ListenableFuture<ArgumentEntry>> futures = new HashMap<>();
for (var entry : ctx.getArguments().entrySet()) {
var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue());
var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts);
futures.put(entry.getKey(), argValueFuture);
}
yield futures;
}
case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts);
case LATEST_VALUES_AGGREGATION -> fetchAggArguments(ctx, entityId, ts);
};
if (ctx.getCfType() == PROPAGATION) {
argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId));
}
return Futures.whenAllComplete(argFutures.values())
.call(() -> resolveArgumentFutures(argFutures),
MoreExecutors.directExecutor());
}
private Map<String, ListenableFuture<ArgumentEntry>> getBaseCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) {
Map<String, ListenableFuture<ArgumentEntry>> futures = new HashMap<>();
for (var entry : ctx.getArguments().entrySet()) {
var argEntityId = resolveEntityId(ctx.getTenantId(), entityId, entry.getValue());
var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), ts);
futures.put(entry.getKey(), argValueFuture);
}
return futures;
}
protected EntityId resolveEntityId(TenantId tenantId, EntityId entityId, Argument argument) {
if (argument.getRefEntityId() != null) {
return argument.getRefEntityId();
@ -155,6 +162,11 @@ public abstract class AbstractCalculatedFieldProcessingService {
));
}
protected ListenableFuture<ArgumentEntry> fetchPropagationCalculatedFieldArgument(CalculatedFieldCtx ctx, EntityId entityId) {
ListenableFuture<List<EntityId>> propagationEntityIds = fromDynamicSource(ctx.getTenantId(), entityId, ctx.getPropagationArgument());
return Futures.transform(propagationEntityIds, ArgumentEntry::createPropagationArgument, MoreExecutors.directExecutor());
}
protected Map<String, ListenableFuture<ArgumentEntry>> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly, long startTs) {
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>();
Set<Map.Entry<String, Argument>> entries = ctx.getArguments().entrySet();
@ -211,6 +223,10 @@ public abstract class AbstractCalculatedFieldProcessingService {
if (!value.hasDynamicSource()) {
return Futures.immediateFuture(List.of(entityId));
}
return fromDynamicSource(tenantId, entityId, value);
}
private ListenableFuture<List<EntityId>> fromDynamicSource(TenantId tenantId, EntityId entityId, Argument value) {
var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration();
return switch (refDynamicSourceConfiguration.getType()) {
case CURRENT_OWNER -> Futures.immediateFuture(List.of(resolveOwnerArgument(tenantId, entityId)));

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

@ -25,7 +25,6 @@ import org.thingsboard.server.actors.calculatedField.MultipleTbCallback;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
@ -52,12 +51,14 @@ import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent
@ -97,11 +98,11 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
@Override
public Map<String, ArgumentEntry> fetchDynamicArgsFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
// only scheduledSupported CF instances supports dynamic arguments scheduled updates
if (!ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) {
return Map.of();
}
return resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis()));
return switch (ctx.getCfType()) {
case GEOFENCING -> resolveArgumentFutures(fetchGeofencingCalculatedFieldArguments(ctx, entityId, true, System.currentTimeMillis()));
case PROPAGATION -> resolveArgumentFutures(Map.of(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId)));
default -> Collections.emptyMap();
};
}
@Override
@ -120,13 +121,35 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
@Override
public void pushMsgToRuleEngine(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List<CalculatedFieldId> cfIds, TbCallback callback) {
try {
if (!(result instanceof PropagationCalculatedFieldResult propagationCalculatedFieldResult)) {
TbMsg msg = result.toTbMsg(entityId, cfIds);
sendMsgToRuleEngine(tenantId, entityId, callback, msg);
return;
}
List<EntityId> propagationEntityIds = propagationCalculatedFieldResult.getPropagationEntityIds();
if (propagationEntityIds.isEmpty()) {
callback.onSuccess();
}
if (propagationEntityIds.size() == 1) {
EntityId propagationEntityId = propagationEntityIds.get(0);
TbMsg msg = result.toTbMsg(propagationEntityId, cfIds);
sendMsgToRuleEngine(tenantId, propagationEntityId, callback, msg);
return;
}
MultipleTbCallback multipleTbCallback = new MultipleTbCallback(propagationEntityIds.size(), callback);
for (var propagationEntityId : propagationEntityIds) {
TbMsg msg = result.toTbMsg(propagationEntityId, cfIds);
sendMsgToRuleEngine(tenantId, propagationEntityId, multipleTbCallback, msg);
}
}
private void sendMsgToRuleEngine(TenantId tenantId, EntityId entityId, TbCallback callback, TbMsg msg) {
try {
clusterService.pushMsgToRuleEngine(tenantId, entityId, msg, new TbQueueCallback() {
@Override
public void onSuccess(TbQueueMsgMetadata metadata) {
callback.onSuccess();
log.trace("[{}][{}] Pushed message to rule engine: {} ", tenantId, entityId, msg);
callback.onSuccess();
}
@Override
@ -135,7 +158,7 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF
}
});
} catch (Exception e) {
log.warn("[{}][{}] Failed to push message to rule engine. CalculatedFieldResult: {}", tenantId, entityId, result, e);
log.warn("[{}][{}] Failed to push message to rule engine: {}", tenantId, entityId, msg, e);
callback.onFailure(e);
}
}

49
application/src/main/java/org/thingsboard/server/service/cf/PropagationCalculatedFieldResult.java

@ -0,0 +1,49 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf;
import lombok.Builder;
import lombok.Data;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.common.msg.TbMsg;
import java.util.List;
@Data
@Builder
public final class PropagationCalculatedFieldResult implements CalculatedFieldResult {
private final List<EntityId> propagationEntityIds;
private final TelemetryCalculatedFieldResult result;
@Override
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) {
return result.toTbMsg(entityId, cfIds);
}
@Override
public String stringValue() {
return result.stringValue();
}
@Override
public boolean isEmpty() {
return CollectionsUtil.isEmpty(propagationEntityIds) || result.isEmpty();
}
}

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

@ -25,6 +25,7 @@ import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.AggArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.aggregation.AggSingleEntityArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import java.util.List;
import java.util.Map;
@ -38,6 +39,7 @@ 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 = AggArgumentEntry.class, name = "AGGREGATE_LATEST"),
@JsonSubTypes.Type(value = AggSingleEntityArgumentEntry.class, name = "AGGREGATE_LATEST_SINGLE")
})
@ -70,6 +72,10 @@ public interface ArgumentEntry {
return new GeofencingArgumentEntry(entityIdkvEntryMap);
}
static ArgumentEntry createPropagationArgument(List<EntityId> entityIds) {
return new PropagationArgumentEntry(entityIds);
}
static ArgumentEntry createAggArgument(Map<EntityId, ArgumentEntry> entityIdkvEntryMap) {
return new AggArgumentEntry(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, AGGREGATE_LATEST, AGGREGATE_LATEST_SINGLE
SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION, AGGREGATE_LATEST, AGGREGATE_LATEST_SINGLE
}

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

@ -15,9 +15,11 @@
*/
package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.Getter;
import lombok.Setter;
import org.thingsboard.script.api.tbel.TbUtils;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.actors.TbActorRef;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
@ -125,7 +127,21 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState,
protected void validateNewEntry(String key, ArgumentEntry newEntry) {}
protected void updateLastUpdateTimestamp(ArgumentEntry entry) {
protected ObjectNode toSimpleResult(boolean useLatestTs, ObjectNode valuesNode) {
if (!useLatestTs) {
return valuesNode;
}
long latestTs = getLatestTimestamp();
if (latestTs == -1) {
return valuesNode;
}
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTs);
resultNode.set("values", valuesNode);
return resultNode;
}
private void updateLastUpdateTimestamp(ArgumentEntry entry) {
long newTs = this.latestTimestamp;
if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
newTs = singleValueArgumentEntry.getTs();

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

@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ExpressionBasedCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
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;
@ -111,6 +112,9 @@ public class CalculatedFieldCtx {
private long scheduledUpdateIntervalMillis;
private Argument propagationArgument;
private boolean applyExpressionForResolvedArguments;
public CalculatedFieldCtx(CalculatedField calculatedField,
ActorSystemContext systemContext) {
this.calculatedField = calculatedField;
@ -171,6 +175,11 @@ public class CalculatedFieldCtx {
}
});
}
if (calculatedField.getConfiguration() instanceof PropagationCalculatedFieldConfiguration propagationConfig) {
propagationArgument = propagationConfig.toPropagationArgument();
applyExpressionForResolvedArguments = propagationConfig.isApplyExpressionToResolvedArguments();
relationQueryDynamicArguments = true;
}
}
if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) {
this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L;
@ -213,6 +222,12 @@ public class CalculatedFieldCtx {
});
initialized = true;
}
case PROPAGATION -> {
if (applyExpressionForResolvedArguments) {
initTbelExpression(expression);
}
initialized = true;
}
case LATEST_VALUES_AGGREGATION -> {
LatestValuesAggregationCalculatedFieldConfiguration configuration = (LatestValuesAggregationCalculatedFieldConfiguration) calculatedField.getConfiguration();
configuration.getMetrics().forEach((key, metric) -> {
@ -566,8 +581,8 @@ public class CalculatedFieldCtx {
return new CalculatedFieldEntityCtxId(tenantId, cfId, entityId);
}
public boolean hasContextOnlyChanges(CalculatedFieldCtx other) {
if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !expression.equals(other.expression)) {
public boolean hasContextOnlyChanges(CalculatedFieldCtx other) { // has changes that do not require state reinit and will be picked up by the state on the fly
if (calculatedField.getConfiguration() instanceof ExpressionBasedCalculatedFieldConfiguration && !Objects.equals(expression, other.expression)) {
return true;
}
if (!Objects.equals(output, other.output)) {
@ -624,22 +639,29 @@ public class CalculatedFieldCtx {
return false;
}
public boolean hasRelationQueryDynamicArguments() {
return relationQueryDynamicArguments && scheduledUpdateIntervalMillis != -1;
private boolean isScheduledUpdateEnabled() {
return scheduledUpdateIntervalMillis != -1;
}
public boolean shouldFetchDynamicArgumentsFromDb(CalculatedFieldState state) {
if (!hasRelationQueryDynamicArguments()) {
public boolean shouldFetchRelationQueryDynamicArgumentsFromDb(CalculatedFieldState state) {
if (!relationQueryDynamicArguments) {
return false;
}
if (!(state instanceof GeofencingCalculatedFieldState geofencingState)) {
return false;
}
if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) {
return true;
}
return geofencingState.getLastDynamicArgumentsRefreshTs() < System.currentTimeMillis() - scheduledUpdateIntervalMillis;
return switch (cfType) {
case PROPAGATION -> true;
case GEOFENCING -> {
if (!isScheduledUpdateEnabled()) {
yield false;
}
var geofencingState = (GeofencingCalculatedFieldState) state;
if (geofencingState.getLastDynamicArgumentsRefreshTs() == -1L) {
yield true;
}
yield geofencingState.getLastDynamicArgumentsRefreshTs() <
System.currentTimeMillis() - scheduledUpdateIntervalMillis;
}
default -> false;
};
}
public void stop() {

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

@ -29,6 +29,7 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
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;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.io.Closeable;
import java.util.Map;
@ -41,7 +42,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg
@Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"),
@Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"),
@Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM")
@Type(value = AlarmCalculatedFieldState.class, name = "ALARM"),
@Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION")
})
public interface CalculatedFieldState extends Closeable {

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

@ -34,7 +34,7 @@ import java.util.Map;
@EqualsAndHashCode(callSuper = true)
public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
private CalculatedFieldScriptEngine tbelExpression;
protected CalculatedFieldScriptEngine tbelExpression;
public ScriptCalculatedFieldState(EntityId entityId) {
super(entityId);

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

@ -71,16 +71,7 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState {
} else {
valuesNode.set(outputName, JacksonUtil.valueToTree(result));
}
long latestTs = getLatestTimestamp();
if (useLatestTs && latestTs != -1) {
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTs);
resultNode.set("values", valuesNode);
return resultNode;
} else {
return valuesNode;
}
return toSimpleResult(useLatestTs, valuesNode);
}
@Override

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

@ -189,8 +189,6 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) {
initCurrentAlarm(ctx);
// FIXME: don't create alarm if attrs were deleted, or config is updated
// TODO: what if expression is changed? do we reevaluate? or only on new events?
TbAlarmResult result = createOrClearAlarms(state -> {
if (updatedArgs != null) {
boolean newEvent = !updatedArgs.isEmpty();
@ -222,10 +220,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState {
private void processAlarmClear(Alarm alarm) {
currentAlarm = null;
createRuleStates.values().forEach(AlarmRuleState::clear);
createRuleStates.clear();
createRuleStates.values().forEach(this::clearState);
clearState(clearRuleState);
clearRuleState = null;
}
private void processAlarmAck(Alarm alarm) {

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

@ -18,7 +18,7 @@ package org.thingsboard.server.service.cf.ctx.state.geofencing;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg;
import org.thingsboard.script.api.tbel.TbelCfGeofencingArg;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.util.ProtoUtils;
@ -83,7 +83,7 @@ public class GeofencingArgumentEntry implements ArgumentEntry {
@Override
public TbelCfArg toTbelCfArg() {
return new TbelCfTsGeofencingArg(zoneStates);
return new TbelCfGeofencingArg(zoneStates);
}
private Map<EntityId, GeofencingZoneState> toZones(Map<EntityId, KvEntry> entityIdKvEntryMap) {

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

@ -146,6 +146,10 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
lastDynamicArgumentsRefreshTs = -1;
}
public void updateLastDynamicArgumentsRefreshTs() {
lastDynamicArgumentsRefreshTs = System.currentTimeMillis();
}
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() {
return arguments.entrySet()
.stream()
@ -168,13 +172,7 @@ public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState {
}
private JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) {
if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) {
return valuesNode;
}
ObjectNode resultNode = JacksonUtil.newObjectNode();
resultNode.put("ts", latestTimestamp);
resultNode.set("values", valuesNode);
return resultNode;
return toSimpleResult(outputType == OutputType.TIME_SERIES, valuesNode);
}
private GeofencingEvalResult aggregateZoneGroup(List<GeofencingEvalResult> zoneResults) {

72
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationArgumentEntry.java

@ -0,0 +1,72 @@
/**
* 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.propagation;
import lombok.Data;
import org.thingsboard.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfPropagationArg;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.util.CollectionsUtil;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType;
import java.util.List;
@Data
public class PropagationArgumentEntry implements ArgumentEntry {
private List<EntityId> propagationEntityIds;
private boolean forceResetPrevious;
public PropagationArgumentEntry(List<EntityId> propagationEntityIds) {
this.propagationEntityIds = propagationEntityIds;
}
@Override
public ArgumentEntryType getType() {
return ArgumentEntryType.PROPAGATION;
}
@Override
public Object getValue() {
return propagationEntityIds;
}
@Override
public boolean updateEntry(ArgumentEntry entry) {
if (!(entry instanceof PropagationArgumentEntry propagationArgumentEntry)) {
throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType());
}
if (propagationArgumentEntry.isEmpty()) {
propagationEntityIds.clear();
} else {
propagationEntityIds = propagationArgumentEntry.getPropagationEntityIds();
}
return true;
}
@Override
public boolean isEmpty() {
return CollectionsUtil.isEmpty(propagationEntityIds);
}
@Override
public TbelCfArg toTbelCfArg() {
return new TbelCfPropagationArg(propagationEntityIds);
}
}

113
application/src/main/java/org/thingsboard/server/service/cf/ctx/state/propagation/PropagationCalculatedFieldState.java

@ -0,0 +1,113 @@
/**
* 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.propagation;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors;
import 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.OutputType;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.CalculatedFieldResult;
import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
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.ScriptCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import java.util.Map;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState {
public PropagationCalculatedFieldState(EntityId entityId) {
super(entityId);
}
@Override
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) {
this.ctx = ctx;
this.actorCtx = actorCtx;
this.requiredArguments = ctx.getArgNames();
if (ctx.isApplyExpressionForResolvedArguments()) {
this.tbelExpression = ctx.getTbelExpressions().get(ctx.getExpression());
}
}
@Override
public boolean isReady() {
if (!super.isReady()) {
return false;
}
ArgumentEntry propagationArg = arguments.get(PROPAGATION_CONFIG_ARGUMENT);
return propagationArg != null && !propagationArg.isEmpty();
}
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.PROPAGATION;
}
@Override
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) {
ArgumentEntry argumentEntry = arguments.get(PROPAGATION_CONFIG_ARGUMENT);
if (!(argumentEntry instanceof PropagationArgumentEntry propagationArgumentEntry) || propagationArgumentEntry.isEmpty()) {
return Futures.immediateFuture(PropagationCalculatedFieldResult.builder().build());
}
if (ctx.isApplyExpressionForResolvedArguments()) {
return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult ->
PropagationCalculatedFieldResult.builder()
.propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds())
.result((TelemetryCalculatedFieldResult) telemetryCfResult)
.build(),
MoreExecutors.directExecutor());
}
return Futures.immediateFuture(PropagationCalculatedFieldResult.builder()
.propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds())
.result(toTelemetryResult(ctx))
.build());
}
private TelemetryCalculatedFieldResult toTelemetryResult(CalculatedFieldCtx ctx) {
Output output = ctx.getOutput();
TelemetryCalculatedFieldResult.TelemetryCalculatedFieldResultBuilder telemetryCfBuilder =
TelemetryCalculatedFieldResult.builder()
.type(output.getType())
.scope(output.getScope());
ObjectNode valuesNode = JacksonUtil.newObjectNode();
arguments.forEach((outputKey, argumentEntry) -> {
if (argumentEntry instanceof PropagationArgumentEntry) {
return;
}
if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) {
JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), outputKey);
return;
}
throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + outputKey + ". " +
"Only Latest telemetry or Attribute arguments supported for 'Arguments Only' propagation mode!");
});
ObjectNode result = toSimpleResult(output.getType() == OutputType.TIME_SERIES, valuesNode);
telemetryCfBuilder.result(result);
return telemetryCfBuilder.build();
}
}

7
application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java

@ -24,6 +24,7 @@ import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor;
import org.thingsboard.server.dao.ai.AiModelService;
import org.thingsboard.server.dao.alarm.AlarmCommentService;
import org.thingsboard.server.dao.alarm.AlarmService;
import org.thingsboard.server.dao.asset.AssetProfileService;
@ -59,6 +60,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor;
import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor;
import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor;
import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor;
import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor;
@ -261,6 +263,11 @@ public class EdgeContextComponent {
@Autowired
private CalculatedFieldProcessor calculatedFieldProcessor;
@Autowired
private AiModelService aiModelService;
@Autowired
private AiModelProcessor aiModelProcessor;
public EdgeProcessor getProcessor(EdgeEventType edgeEventType) {
EdgeProcessor processor = processorMap.get(edgeEventType);
if (processor == null) {

4
application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java

@ -113,7 +113,7 @@ public class EdgeEventSourcingListener {
return;
}
try {
if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == entityType) {
if (EntityType.TENANT == entityType || EntityType.EDGE == entityType) {
return;
}
log.trace("[{}] DeleteEntityEvent called: {}", tenantId, event);
@ -227,7 +227,7 @@ public class EdgeEventSourcingListener {
break;
case TENANT:
return !event.getCreated();
case API_USAGE_STATE, EDGE, AI_MODEL:
case API_USAGE_STATE, EDGE:
return false;
case DOMAIN:
if (entity instanceof Domain domain) {

16
application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java

@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.TbResource;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmComment;
import org.thingsboard.server.common.data.asset.Asset;
@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.domain.DomainInfo;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEventActionType;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.AssetProfileId;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
@ -86,6 +88,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.widget.WidgetTypeDetails;
import org.thingsboard.server.common.data.widget.WidgetsBundle;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
@ -654,4 +657,17 @@ public class EdgeMsgConstructorUtils {
.setIdLSB(calculatedFieldId.getId().getLeastSignificantBits()).build();
}
public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) {
return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel))
.setIdMSB(aiModel.getId().getId().getMostSignificantBits())
.setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build();
}
public static AiModelUpdateMsg constructAiModelDeleteMsg(AiModelId aiModelId) {
return AiModelUpdateMsg.newBuilder()
.setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE)
.setIdMSB(aiModelId.getId().getMostSignificantBits())
.setIdLSB(aiModelId.getId().getLeastSignificantBits()).build();
}
}

6
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java

@ -46,6 +46,7 @@ import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.dao.edge.stats.EdgeStatsKey;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
@ -934,6 +935,11 @@ public abstract class EdgeGrpcSession implements Closeable {
result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg));
}
}
if (uplinkMsg.getAiModelUpdateMsgCount() > 0) {
for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) {
result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg));
}
}
} catch (Exception e) {
String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e);

2
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/BaseEdgeProcessor.java

@ -139,7 +139,7 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor {
UPDATED_COMMENT, DELETED -> true;
default -> switch (type) {
case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE,
WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, NOTIFICATION_TEMPLATE,
WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE,
NOTIFICATION_TARGET, NOTIFICATION_RULE -> true;
default -> false;
};

130
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelEdgeProcessor.java

@ -0,0 +1,130 @@
/**
* 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.edge.rpc.processor.ai;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.util.Pair;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventActionType;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Component
@TbCoreComponent
public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiModelProcessor {
@Override
public ListenableFuture<Void> processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg) {
AiModelId aiModelId = new AiModelId(new UUID(aiModelUpdateMsg.getIdMSB(), aiModelUpdateMsg.getIdLSB()));
try {
edgeSynchronizationManager.getEdgeId().set(edge.getId());
switch (aiModelUpdateMsg.getMsgType()) {
case ENTITY_CREATED_RPC_MESSAGE:
case ENTITY_UPDATED_RPC_MESSAGE:
processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge);
return Futures.immediateFuture(null);
case UNRECOGNIZED:
default:
return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType());
}
} catch (DataValidationException e) {
return Futures.immediateFailedFuture(e);
} finally {
edgeSynchronizationManager.getEdgeId().remove();
}
}
@Override
public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) {
AiModelId aiModelId = new AiModelId(edgeEvent.getEntityId());
switch (edgeEvent.getAction()) {
case ADDED, UPDATED -> {
Optional<AiModel> aiModel = edgeCtx.getAiModelService().findAiModelById(edgeEvent.getTenantId(), aiModelId);
if (aiModel.isPresent()) {
UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction());
AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelUpdatedMsg(msgType, aiModel.get());
return DownlinkMsg.newBuilder()
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.addAiModelUpdateMsg(aiModelUpdateMsg)
.build();
}
}
case DELETED -> {
AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelDeleteMsg(aiModelId);
return DownlinkMsg.newBuilder()
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.addAiModelUpdateMsg(aiModelUpdateMsg)
.build();
}
}
return null;
}
@Override
public EdgeEventType getEdgeEventType() {
return EdgeEventType.AI_MODEL;
}
private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) {
Pair<Boolean, Boolean> resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg);
Boolean wasCreated = resultPair.getFirst();
if (wasCreated) {
pushAiModelCreatedEventToRuleEngine(tenantId, edge, aiModelId);
}
Boolean nameWasUpdated = resultPair.getSecond();
if (nameWasUpdated) {
saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.AI_MODEL, EdgeEventActionType.UPDATED, aiModelId, null);
}
}
private void pushAiModelCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AiModelId aiModelId) {
try {
Optional<AiModel> aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId);
if (aiModel.isPresent()) {
String aiModelAsString = JacksonUtil.toString(aiModel.get());
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId());
pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData);
} else {
log.warn("[{}][{}] Failed to find aiModel", tenantId, aiModelId);
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e);
}
}
}

28
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/AiModelProcessor.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.edge.rpc.processor.ai;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor;
public interface AiModelProcessor extends EdgeProcessor {
ListenableFuture<Void> processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg);
}

81
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/ai/BaseAiModelProcessor.java

@ -0,0 +1,81 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor.ai;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.util.Pair;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.id.AiModelId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor;
import java.util.Optional;
@Slf4j
public abstract class BaseAiModelProcessor extends BaseEdgeProcessor {
@Autowired
private DataValidator<AiModel> aiModelValidator;
protected Pair<Boolean, Boolean> saveOrUpdateAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg) {
boolean isCreated = false;
boolean isNameUpdated = false;
try {
AiModel aiModel = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true);
if (aiModel == null) {
throw new RuntimeException("[{" + tenantId + "}] aiModelUpdateMsg {" + aiModelUpdateMsg + " } cannot be converted to aiModel");
}
Optional<AiModel> aiModelById = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId);
if (aiModelById.isEmpty()) {
aiModel.setCreatedTime(Uuids.unixTimestamp(aiModelId.getId()));
isCreated = true;
aiModel.setId(null);
} else {
aiModel.setId(aiModelId);
}
String aiModelName = aiModel.getName();
Optional<AiModel> aiModelByName = edgeCtx.getAiModelService().findAiModelByTenantIdAndName(aiModel.getTenantId(), aiModelName);
if (aiModelByName.isPresent() && !aiModelByName.get().getId().equals(aiModelId)) {
aiModelName = aiModelName + "_" + StringUtils.randomAlphabetic(15);
log.warn("[{}] aiModel with name {} already exists. Renaming aiModel name to {}",
tenantId, aiModel.getName(), aiModelByName.get().getName());
isNameUpdated = true;
}
aiModel.setName(aiModelName);
aiModelValidator.validate(aiModel, AiModel::getTenantId);
if (isCreated) {
aiModel.setId(aiModelId);
}
edgeCtx.getAiModelService().save(aiModel, false);
} catch (Exception e) {
log.error("[{}] Failed to process aiModel update msg [{}]", tenantId, aiModelUpdateMsg, e);
throw e;
}
return Pair.of(isCreated, isNameUpdated);
}
}

8
application/src/main/java/org/thingsboard/server/service/entitiy/asset/DefaultTbAssetService.java

@ -20,6 +20,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.audit.ActionType;
@ -40,10 +41,15 @@ public class DefaultTbAssetService extends AbstractTbEntityService implements Tb
@Override
public Asset save(Asset asset, User user) throws Exception {
return save(asset, NameConflictStrategy.DEFAULT, user);
}
@Override
public Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception {
ActionType actionType = asset.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = asset.getTenantId();
try {
Asset savedAsset = checkNotNull(assetService.saveAsset(asset));
Asset savedAsset = checkNotNull(assetService.saveAsset(asset, nameConflictStrategy));
autoCommit(user, savedAsset.getId());
logEntityActionService.logEntityAction(tenantId, savedAsset.getId(), savedAsset, asset.getCustomerId(),
actionType, user);

3
application/src/main/java/org/thingsboard/server/service/entitiy/asset/TbAssetService.java

@ -16,6 +16,7 @@
package org.thingsboard.server.service.entitiy.asset;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.edge.Edge;
@ -27,6 +28,8 @@ public interface TbAssetService {
Asset save(Asset asset, User user) throws Exception;
Asset save(Asset asset, NameConflictStrategy nameConflictStrategy, User user) throws Exception;
void delete(Asset asset, User user);
Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, Customer customer, User user) throws ThingsboardException;

8
application/src/main/java/org/thingsboard/server/service/entitiy/customer/DefaultTbCustomerService.java

@ -19,6 +19,7 @@ import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.id.CustomerId;
@ -32,10 +33,15 @@ public class DefaultTbCustomerService extends AbstractTbEntityService implements
@Override
public Customer save(Customer customer, SecurityUser user) throws Exception {
return save(customer, NameConflictStrategy.DEFAULT, user);
}
@Override
public Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception {
ActionType actionType = customer.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = customer.getTenantId();
try {
Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer));
Customer savedCustomer = checkNotNull(customerService.saveCustomer(customer, nameConflictStrategy));
autoCommit(user, savedCustomer.getId());
logEntityActionService.logEntityAction(tenantId, savedCustomer.getId(), savedCustomer, null, actionType, user);
return savedCustomer;

4
application/src/main/java/org/thingsboard/server/service/entitiy/customer/TbCustomerService.java

@ -16,8 +16,12 @@
package org.thingsboard.server.service.entitiy.customer;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.service.entitiy.SimpleTbEntityService;
import org.thingsboard.server.service.security.model.SecurityUser;
public interface TbCustomerService extends SimpleTbEntityService<Customer> {
Customer save(Customer customer, NameConflictStrategy nameConflictStrategy, SecurityUser user) throws Exception;
}

15
application/src/main/java/org/thingsboard/server/service/entitiy/device/DefaultTbDeviceService.java

@ -25,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
@ -56,10 +57,15 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T
@Override
public Device save(Device device, String accessToken, User user) throws Exception {
return save(device, accessToken, NameConflictStrategy.DEFAULT, user);
}
@Override
public Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception {
ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = device.getTenantId();
try {
Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken));
Device savedDevice = checkNotNull(deviceService.saveDeviceWithAccessToken(device, accessToken, nameConflictStrategy));
autoCommit(user, savedDevice.getId());
logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(),
actionType, user);
@ -73,10 +79,15 @@ public class DefaultTbDeviceService extends AbstractTbEntityService implements T
@Override
public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, User user) throws ThingsboardException {
return saveDeviceWithCredentials(device, credentials, NameConflictStrategy.DEFAULT, user);
}
@Override
public Device saveDeviceWithCredentials(Device device, DeviceCredentials credentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException {
ActionType actionType = device.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = device.getTenantId();
try {
Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials));
Device savedDevice = checkNotNull(deviceService.saveDeviceWithCredentials(device, credentials, nameConflictStrategy));
logEntityActionService.logEntityAction(tenantId, savedDevice.getId(), savedDevice, savedDevice.getCustomerId(),
actionType, user);

5
application/src/main/java/org/thingsboard/server/service/entitiy/device/TbDeviceService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.device;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
@ -33,8 +34,12 @@ public interface TbDeviceService {
Device save(Device device, String accessToken, User user) throws Exception;
Device save(Device device, String accessToken, NameConflictStrategy nameConflictStrategy, User user) throws Exception;
Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, User user) throws ThingsboardException;
Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy, User user) throws ThingsboardException;
void delete(Device device, User user);
Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, Customer customer, User user) throws ThingsboardException;

8
application/src/main/java/org/thingsboard/server/service/entitiy/entityview/DefaultTbEntityViewService.java

@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
@ -80,10 +81,15 @@ public class DefaultTbEntityViewService extends AbstractTbEntityService implemen
@Override
public EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception {
return save(entityView, existingEntityView, NameConflictStrategy.DEFAULT, user);
}
@Override
public EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception {
ActionType actionType = entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED;
TenantId tenantId = entityView.getTenantId();
try {
EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView));
EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView, nameConflictStrategy));
this.updateEntityViewAttributes(tenantId, savedEntityView, existingEntityView, user);
autoCommit(user, savedEntityView.getId());
logEntityActionService.logEntityAction(savedEntityView.getTenantId(), savedEntityView.getId(), savedEntityView,

3
application/src/main/java/org/thingsboard/server/service/entitiy/entityview/TbEntityViewService.java

@ -18,6 +18,7 @@ package org.thingsboard.server.service.entitiy.entityview;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@ -33,6 +34,8 @@ public interface TbEntityViewService extends ComponentLifecycleListener {
EntityView save(EntityView entityView, EntityView existingEntityView, User user) throws Exception;
EntityView save(EntityView entityView, EntityView existingEntityView, NameConflictStrategy nameConflictStrategy, User user) throws Exception;
void updateEntityViewAttributes(TenantId tenantId, EntityView savedEntityView, EntityView oldEntityView, User user) throws ThingsboardException;
void delete(EntityView entity, User user) throws ThingsboardException;

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

@ -17,6 +17,7 @@ package org.thingsboard.server.service.sync.ie.importing.csv;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonPrimitive;
import jakarta.annotation.Nullable;
@ -183,7 +184,13 @@ public abstract class AbstractBulkImportService<E extends HasId<? extends Entity
data.entrySet().stream()
.filter(dataEntry -> dataEntry.getKey().getType() == kvType &&
StringUtils.isNotEmpty(dataEntry.getKey().getKey()))
.forEach(dataEntry -> kvs.add(dataEntry.getKey().getKey(), dataEntry.getValue().toJsonPrimitive()));
.forEach(dataEntry -> {
ParsedValue value = dataEntry.getValue();
JsonElement kvValue = (value.getDataType() == DataType.JSON)
? (JsonElement) value.getValue()
: value.toJsonPrimitive();
kvs.add(dataEntry.getKey().getKey(), kvValue);
});
return Map.entry(kvType, kvs);
})
.filter(kvsEntry -> kvsEntry.getValue().entrySet().size() > 0)

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

@ -38,6 +38,7 @@ import org.thingsboard.server.service.cf.ctx.state.aggregation.AggSingleEntityAr
import org.thingsboard.server.service.cf.ctx.state.aggregation.LatestValuesAggregationCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.Optional;
@ -89,6 +90,7 @@ public class CalculatedFieldArgumentUtils {
case SCRIPT -> new ScriptCalculatedFieldState(entityId);
case GEOFENCING -> new GeofencingCalculatedFieldState(entityId);
case ALARM -> new AlarmCalculatedFieldState(entityId);
case PROPAGATION -> new PropagationCalculatedFieldState(entityId);
case LATEST_VALUES_AGGREGATION -> new LatestValuesAggregationCalculatedFieldState(entityId);
};
}

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

@ -55,6 +55,7 @@ import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.HashMap;
import java.util.Map;
@ -99,15 +100,15 @@ public class CalculatedFieldUtils {
LatestValuesAggregationStateProto.Builder aggBuilder = LatestValuesAggregationStateProto.newBuilder();
state.getArguments().forEach((argName, argEntry) -> {
if (argEntry instanceof AggArgumentEntry aggArgumentEntry) {
aggArgumentEntry.getAggInputs()
.forEach((entityId, entry) -> aggBuilder.addAggArguments(toAggSingleArgumentProto(argName, entityId, entry)));
} else if (argEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
builder.addSingleValueArguments(toSingleValueArgumentProto(argName, singleValueArgumentEntry));
} else if (argEntry instanceof TsRollingArgumentEntry rollingArgumentEntry) {
builder.addRollingValueArguments(toRollingArgumentProto(argName, rollingArgumentEntry));
} else if (argEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry) {
builder.addGeofencingArguments(toGeofencingArgumentProto(argName, geofencingArgumentEntry));
switch (argEntry.getType()) {
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 AGGREGATE_LATEST -> {
AggArgumentEntry aggArgumentEntry = (AggArgumentEntry) argEntry;
aggArgumentEntry.getAggInputs()
.forEach((entityId, entry) -> aggBuilder.addAggArguments(toAggSingleArgumentProto(argName, entityId, entry)));
}
}
});
if (state instanceof AlarmCalculatedFieldState alarmState) {
@ -212,6 +213,7 @@ public class CalculatedFieldUtils {
case SCRIPT -> new ScriptCalculatedFieldState(id.entityId());
case GEOFENCING -> new GeofencingCalculatedFieldState(id.entityId());
case ALARM -> new AlarmCalculatedFieldState(id.entityId());
case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId());
case LATEST_VALUES_AGGREGATION -> new LatestValuesAggregationCalculatedFieldState(id.entityId());
};

19
application/src/main/java/org/thingsboard/server/utils/CsvUtils.java

@ -17,10 +17,15 @@ package org.thingsboard.server.utils;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.SneakyThrows;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVPrinter;
import org.apache.commons.csv.CSVRecord;
import org.apache.commons.io.input.CharSequenceReader;
import java.io.ByteArrayOutputStream;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@ -43,4 +48,18 @@ public class CsvUtils {
.collect(Collectors.toList());
}
@SneakyThrows
public static byte[] generateCsv(List<List<String>> rows) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
CSVPrinter csvPrinter = new CSVPrinter(writer, CSVFormat.DEFAULT)) {
for (List<String> row : rows) {
csvPrinter.printRecord(row);
}
csvPrinter.flush();
}
return out.toByteArray();
}
}

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

@ -29,6 +29,7 @@ import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.alarm.Alarm;
import org.thingsboard.server.common.data.alarm.AlarmInfo;
import org.thingsboard.server.common.data.alarm.AlarmSeverity;
import org.thingsboard.server.common.data.alarm.AlarmStatus;
import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
@ -689,16 +690,49 @@ public class AlarmRulesTest extends AbstractControllerTest {
});
}
@Test
public void testManualClearAlarm() throws Exception {
Argument temperatureArgument = new Argument();
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
temperatureArgument.setDefaultValue("0");
Map<String, Argument> arguments = Map.of(
"temperature", temperatureArgument
);
Map<AlarmSeverity, Condition> createRules = Map.of(
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null)
);
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm",
arguments, createRules, null);
postTelemetry(deviceId, "{\"temperature\":50}");
Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> {
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
}).getAlarm();
doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class);
Thread.sleep(1000);
postTelemetry(deviceId, "{\"temperature\":50}");
checkAlarmResult(calculatedField, alarmResult -> {
assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId());
assertThat(alarmResult.isCreated()).isTrue();
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL);
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK);
});
}
// TODO: MSA tests
// TODO: test when attribute or telemetry is deleted without default value - perform calculation not happens
private void checkAlarmResult(CalculatedField calculatedField, Consumer<TbAlarmResult> assertion) {
checkAlarmResult(calculatedField, null, assertion);
private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer<TbAlarmResult> assertion) {
return checkAlarmResult(calculatedField, null, assertion);
}
private void checkAlarmResult(CalculatedField calculatedField,
Predicate<TbAlarmResult> waitFor,
Consumer<TbAlarmResult> assertion) {
private TbAlarmResult checkAlarmResult(CalculatedField calculatedField,
Predicate<TbAlarmResult> waitFor,
Consumer<TbAlarmResult> assertion) {
TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS)
.until(() -> getLatestAlarmResult(calculatedField.getId()), result ->
result != null && (waitFor == null || waitFor.test(result)));
@ -707,6 +741,7 @@ public class AlarmRulesTest extends AbstractControllerTest {
Alarm alarm = alarmResult.getAlarm();
assertThat(alarm.getOriginator()).isEqualTo(originatorId);
assertThat(alarm.getType()).isEqualTo(calculatedField.getName());
return alarmResult;
}
private TbAlarmResult getLatestAlarmResult(CalculatedFieldId calculatedFieldId) {

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

@ -17,6 +17,7 @@ package org.thingsboard.server.cf;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.thingsboard.common.util.JacksonUtil;
@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetProfile;
@ -34,6 +36,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ScriptCalculatedFieldConfiguration;
@ -997,6 +1000,169 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes
});
}
@Test
public void testPropagationCalculatedField_withExpression() throws Exception {
// --- Arrange entities ---
Device device = createDevice("Propagation Device With Expression", "sn-prop-1");
Asset asset1 = createAsset("Propagated Asset 1", null);
Asset asset2 = createAsset("Propagated Asset 2", null);
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel1).andExpect(status().isOk());
doPost("/api/relation", rel2).andExpect(status().isOk());
// Telemetry on device
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"temperature\":12.5}")).andExpect(status().isOk());
// --- Build CF: PROPAGATION with expression ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.PROPAGATION);
cf.setName("Propagation CF (expr)");
cf.setConfigurationVersion(1);
PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setApplyExpressionToResolvedArguments(true);
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
cfg.setArguments(Map.of("t", arg));
cfg.setExpression("{\"testResult\": t * 2}");
Output output = new Output();
output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
cfg.setOutput(output);
cf.setConfiguration(cfg);
doPost("/api/calculatedField", cf, CalculatedField.class);
// --- Assert propagated calculation (expression applied) ---
await().alias("propagation expr mode evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult");
ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult");
assertThat(attrs1).isNotNull();
assertThat(attrs2).isNotNull();
assertThat(attrs1.get(0).get("value").asDouble()).isEqualTo(25.0);
assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(25.0);
});
String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
asset1.getId().getId(), EntityType.ASSET,
EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE
);
doDelete(deleteUrl).andExpect(status().isOk());
doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/SERVER_SCOPE?keys=testResult").andExpect(status().isOk());
doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope",
JacksonUtil.toJsonNode("{\"temperature\":25}")).andExpect(status().isOk());
// --- Assert propagated calculation (expression applied with new temperature argument and one relation removed) ---
await().alias("propagation expr mode evaluation after temperature update")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ArrayNode attrs1 = getServerAttributes(asset1.getId(), "testResult");
ArrayNode attrs2 = getServerAttributes(asset2.getId(), "testResult");
assertThat(attrs1).isNullOrEmpty();
assertThat(attrs2).isNotNull();
assertThat(attrs2.get(0).get("value").asDouble()).isEqualTo(50);
});
}
@Test
public void testPropagationCalculatedField_withoutExpression() throws Exception {
// --- Arrange entities ---
Device device = createDevice("Propagation Device Without Expression", "sn-prop-2");
Asset asset1 = createAsset("Propagated Asset 1", null);
Asset asset2 = createAsset("Propagated Asset 2", null);
// Create relations FROM assets TO device
EntityRelation rel1 = new EntityRelation(asset1.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE);
doPost("/api/relation", rel1).andExpect(status().isOk());
doPost("/api/relation", rel2).andExpect(status().isOk());
// Telemetry on device
long ts = System.currentTimeMillis() - 300000L;
postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":12.5}}", ts));
// --- Build CF: PROPAGATION without expression ---
CalculatedField cf = new CalculatedField();
cf.setEntityId(device.getId());
cf.setType(CalculatedFieldType.PROPAGATION);
cf.setName("Propagation CF (args-only)");
cf.setConfigurationVersion(1);
PropagationCalculatedFieldConfiguration cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
cfg.setApplyExpressionToResolvedArguments(false); // arguments-only mode
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
cfg.setArguments(Map.of("temperatureComputed", arg));
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
cfg.setOutput(output);
cf.setConfiguration(cfg);
doPost("/api/calculatedField", cf, CalculatedField.class);
// --- Assert propagated calculation (arguments-only mode) ---
await().alias("propagation args-only evaluation")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed");
ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed");
assertThat(telemetry1).isNotNull();
assertThat(telemetry2).isNotNull();
assertThat(telemetry1.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
assertThat(telemetry1.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5);
assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(ts));
assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(12.5);
});
String deleteUrl = String.format("/api/v2/relation?fromId=%s&fromType=%s&relationType=%s&toId=%s&toType=%s",
asset1.getId().getId(), EntityType.ASSET,
EntityRelation.CONTAINS_TYPE, device.getId().getId(), EntityType.DEVICE
);
doDelete(deleteUrl).andExpect(status().isOk());
doDelete("/api/plugins/telemetry/ASSET/" + asset1.getId() + "/timeseries/delete?keys=temperatureComputed&deleteAllDataForKeys=true").andExpect(status().isOk());
// Update telemetry on device
long newTs = System.currentTimeMillis() - 300000L;
postTelemetry(device.getId(), String.format("{\"ts\": %s, \"values\": {\"temperature\":25}}", newTs));
// --- Assert propagated calculation (arguments-only mode after update) ---
await().alias("propagation args-only evaluation after temperature update")
.atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS)
.untilAsserted(() -> {
ObjectNode telemetry1 = getLatestTelemetry(asset1.getId(), "temperatureComputed");
ObjectNode telemetry2 = getLatestTelemetry(asset2.getId(), "temperatureComputed");
assertThat(telemetry1).isNotNull();
assertThat(telemetry2).isNotNull();
assertThat(telemetry1.get("temperatureComputed").get(0).get("value")).isEqualTo(NullNode.instance);
assertThat(telemetry2.get("temperatureComputed").get(0).get("ts").asText()).isEqualTo(Long.toString(newTs));
assertThat(telemetry2.get("temperatureComputed").get(0).get("value").asDouble()).isEqualTo(25);
});
}
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);
}

24
application/src/test/java/org/thingsboard/server/controller/AssetControllerTest.java

@ -1080,6 +1080,30 @@ public class AssetControllerTest extends AbstractControllerTest {
testEntityDaoWithRelationsTransactionalException(assetDao, savedTenant.getId(), assetId, "/api/asset/" + assetId);
}
@Test
public void testSaveAssetWithUniquifyStrategy() throws Exception {
Asset asset = new Asset();
asset.setName("My unique asset");
asset.setType("default");
doPost("/api/asset", asset, Asset.class);
doPost("/api/asset", asset).andExpect(status().isBadRequest());
doPost("/api/asset?nameConflictPolicy=FAIL", asset).andExpect(status().isBadRequest());
Asset secondAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY", asset, Asset.class);
assertThat(secondAsset.getName()).startsWith("My unique asset_");
Asset thirdAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", asset, Asset.class);
assertThat(thirdAsset.getName()).startsWith("My unique asset-");
Asset fourthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class);
assertThat(fourthAsset.getName()).isEqualTo("My unique asset_1");
Asset fifthAsset = doPost("/api/asset?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", asset, Asset.class);
assertThat(fifthAsset.getName()).isEqualTo("My unique asset_2");
}
private Asset createAsset(String name) {
Asset asset = new Asset();
asset.setName(name);

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

@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration;
@ -36,6 +37,7 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.Geofencing
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.page.PageLink;
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.security.Authority;
@ -45,6 +47,7 @@ import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS;
@ -82,7 +85,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testSaveCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@ -110,7 +113,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testSaveGeofencingCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), getGeofencingCalculatedFieldConfig());
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.GEOFENCING);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@ -135,10 +138,48 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
.andExpect(status().isOk());
}
@Test
public void testSavePropagationCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION);
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
assertThat(savedCalculatedField).isNotNull();
assertThat(savedCalculatedField.getId()).isNotNull();
assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0);
assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId());
assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId());
assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType());
assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName());
assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getPropagationCalculatedFieldConfig());
assertThat(savedCalculatedField.getVersion()).isEqualTo(1L);
savedCalculatedField.setName("Test CF");
CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class);
assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName());
assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1);
doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString())
.andExpect(status().isOk());
}
@Test
public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.PROPAGATION, getPropagationCalculatedFieldConfig(null));
doPost("/api/calculatedField", calculatedField)
.andExpect(status().isBadRequest())
.andExpect(statusReason(containsString("arguments must not be empty")));
}
@Test
public void testGetCalculatedFieldById() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class);
@ -153,7 +194,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testGetCalculatedFields() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
calculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
assertThat(getCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData())
@ -165,7 +206,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
@Test
public void testDeleteCalculatedField() throws Exception {
Device testDevice = createDevice("Test device", "1234567890");
CalculatedField calculatedField = getCalculatedField(testDevice.getId());
CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId());
CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class);
@ -176,17 +217,27 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
doGet("/api/calculatedField/" + savedCalculatedField.getId().getId()).andExpect(status().isNotFound());
}
private CalculatedField getCalculatedField(EntityId entityId) {
return getCalculatedField(entityId, getSimpleCalculatedFieldConfig());
private CalculatedField getSimpleCalculatedField(EntityId entityId) {
return getCalculatedField(entityId, CalculatedFieldType.SIMPLE);
}
private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType) {
return getCalculatedField(entityId, cfType, null);
}
private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldConfiguration configuration) {
private CalculatedField getCalculatedField(EntityId entityId, CalculatedFieldType cfType, CalculatedFieldConfiguration customConfiguration) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setEntityId(entityId);
calculatedField.setType(CalculatedFieldType.SIMPLE);
calculatedField.setType(cfType);
calculatedField.setName("Test Calculated Field");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(configuration);
if (customConfiguration != null) {
calculatedField.setConfiguration(customConfiguration);
} else switch (cfType) {
case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig());
case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig());
case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig());
}
calculatedField.setVersion(1L);
return calculatedField;
}
@ -211,6 +262,32 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest {
return config;
}
private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig() {
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
return getPropagationCalculatedFieldConfig(Map.of("t", arg));
}
private CalculatedFieldConfiguration getPropagationCalculatedFieldConfig(Map<String, Argument> arguments) {
var config = new PropagationCalculatedFieldConfiguration();
config.setRelationType(EntityRelation.CONTAINS_TYPE);
config.setDirection(EntitySearchDirection.TO);
config.setApplyExpressionToResolvedArguments(false);
config.setExpression(null);
Output output = new Output();
output.setType(OutputType.TIME_SERIES);
config.setOutput(output);
Argument arg = new Argument();
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null));
config.setArguments(arguments);
return config;
}
private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() {
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();

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

@ -33,6 +33,7 @@ import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ContextConfiguration;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant;
import org.thingsboard.server.common.data.User;
@ -462,6 +463,27 @@ public class CustomerControllerTest extends AbstractControllerTest {
testEntityDaoWithRelationsTransactionalException(customerDao, savedTenant.getId(), customerId, "/api/customer/" + customerId);
}
@Test
public void testSaveCustomerWithUniquifyStrategy() throws Exception {
Customer customer = new Customer();
customer.setTitle("My unique customer");
Customer savedCustomer = doPost("/api/customer", customer, Customer.class);
doPost("/api/customer?nameConflictPolicy=FAIL", customer).andExpect(status().isBadRequest());
Customer secondCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY", customer, Customer.class);
assertThat(secondCustomer.getName()).startsWith("My unique customer_");
Customer thirdCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", customer, Customer.class);
assertThat(thirdCustomer.getName()).startsWith("My unique customer-");
Customer fourthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class);
assertThat(fourthCustomer.getName()).isEqualTo("My unique customer_1");
Customer fifthCustomer = doPost("/api/customer?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", customer, Customer.class);
assertThat(fifthCustomer.getName()).isEqualTo("My unique customer_2");
}
private Customer createCustomer(String title) {
Customer customer = new Customer();
customer.setTitle(title);

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

@ -38,6 +38,7 @@ import org.springframework.test.context.ContextConfiguration;
import org.testcontainers.shaded.org.awaitility.Awaitility;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceInfo;
@ -59,6 +60,7 @@ import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceCredentialsId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.relation.EntityRelation;
@ -77,10 +79,15 @@ import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.service.gateway_device.GatewayNotificationsService;
import org.thingsboard.server.service.state.DeviceStateService;
import org.thingsboard.server.utils.CsvUtils;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
@ -1586,6 +1593,56 @@ public class DeviceControllerTest extends AbstractControllerTest {
Assert.assertEquals(newAttributeValue, actualAttribute.get("value"));
}
@Test
public void testBulkImportDeviceWithJsonAttr() throws Exception {
String deviceName = "some_device";
String deviceType = "some_type";
String deviceAttr = "{\"threshold\":45}";
List<List<String>> content = new LinkedList<>();
content.add(Arrays.asList("NAME", "TYPE", "ATTR"));
content.add(Arrays.asList(deviceName, deviceType, deviceAttr));
byte[] bytes = CsvUtils.generateCsv(content);
BulkImportRequest request = new BulkImportRequest();
request.setFile(new String(bytes, StandardCharsets.UTF_8));
BulkImportRequest.Mapping mapping = new BulkImportRequest.Mapping();
BulkImportRequest.ColumnMapping name = new BulkImportRequest.ColumnMapping();
name.setType(BulkImportColumnType.NAME);
BulkImportRequest.ColumnMapping type = new BulkImportRequest.ColumnMapping();
type.setType(BulkImportColumnType.TYPE);
BulkImportRequest.ColumnMapping attr = new BulkImportRequest.ColumnMapping();
attr.setType(BulkImportColumnType.SERVER_ATTRIBUTE);
attr.setKey("attr");
List<BulkImportRequest.ColumnMapping> columns = new ArrayList<>();
columns.add(name);
columns.add(type);
columns.add(attr);
mapping.setColumns(columns);
mapping.setDelimiter(',');
mapping.setUpdate(true);
mapping.setHeader(true);
request.setMapping(mapping);
BulkImportResult<Device> deviceBulkImportResult = doPostWithTypedResponse("/api/device/bulk_import", request, new TypeReference<>() {});
Assert.assertEquals(1, deviceBulkImportResult.getCreated().get());
Assert.assertEquals(0, deviceBulkImportResult.getErrors().get());
Assert.assertEquals(0, deviceBulkImportResult.getUpdated().get());
Assert.assertTrue(deviceBulkImportResult.getErrorsList().isEmpty());
Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class);
Assert.assertNotNull(savedDevice);
Assert.assertEquals(deviceName, savedDevice.getName());
Assert.assertEquals(deviceType, savedDevice.getType());
Optional<AttributeKvEntry> retrieved = attributesService.find(tenantId, savedDevice.getId(), AttributeScope.SERVER_SCOPE, "attr").get();
assertThat(retrieved.get().getJsonValue().get()).isEqualTo(deviceAttr);
assertThat(retrieved.get().getStrValue()).isNotPresent();
}
@Test
public void testSaveDeviceWithOutdatedVersion() throws Exception {
Device device = createDevice("Device v1.0");
@ -1608,6 +1665,30 @@ public class DeviceControllerTest extends AbstractControllerTest {
assertThat(device.getVersion()).isEqualTo(3);
}
@Test
public void testSaveDeviceWithUniquifyStrategy() throws Exception {
Device device = new Device();
device.setName("My unique device");
device.setType("default");
Device savedDevice = doPost("/api/device", device, Device.class);
doPost("/api/device", device).andExpect(status().isBadRequest());
doPost("/api/device?nameConflictPolicy=FAIL", device).andExpect(status().isBadRequest());
Device secondDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY", device, Device.class);
assertThat(secondDevice.getName()).startsWith("My unique device_");
Device thirdDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", device, Device.class);
assertThat(thirdDevice.getName()).startsWith("My unique device-");
Device fourthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class);
assertThat(fourthDevice.getName()).isEqualTo("My unique device_1");
Device fifthDevice = doPost("/api/device?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", device, Device.class);
assertThat(fifthDevice.getName()).isEqualTo("My unique device_2");
}
private Device createDevice(String name) {
Device device = new Device();
device.setName(name);

25
application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java

@ -853,4 +853,29 @@ public class EntityViewControllerTest extends AbstractControllerTest {
EntityViewId entityViewId = getNewSavedEntityView("EntityView for Test WithRelations Transactional Exception").getId();
testEntityDaoWithRelationsTransactionalException(entityViewDao, tenantId, entityViewId, "/api/entityView/" + entityViewId);
}
@Test
public void testSaveEntityViewWithUniquifyStrategy() throws Exception {
EntityView view = new EntityView();
view.setEntityId(testDevice.getId());
view.setTenantId(tenantId);
view.setType("default");
view.setName("My unique view");
EntityView savedView = doPost("/api/entityView", view, EntityView.class);
doPost("/api/entityView?nameConflictPolicy=FAIL", view).andExpect(status().isBadRequest());
EntityView secondView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY", view, EntityView.class);
assertThat(secondView.getName()).startsWith("My unique view_");
EntityView thirdView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifySeparator=-", view, EntityView.class);
assertThat(thirdView.getName()).startsWith("My unique view-");
EntityView fourthView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class);
assertThat(fourthView.getName()).isEqualTo("My unique view_1");
EntityView fifthEntityView = doPost("/api/entityView?nameConflictPolicy=UNIQUIFY&uniquifyStrategy=INCREMENTAL", view, EntityView.class);
assertThat(fifthEntityView.getName()).isEqualTo("My unique view_2");
}
}

196
application/src/test/java/org/thingsboard/server/edge/AiModelEdgeTest.java

@ -0,0 +1,196 @@
/**
* 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.edge;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.google.protobuf.AbstractMessage;
import com.google.protobuf.InvalidProtocolBufferException;
import org.junit.Assert;
import org.junit.Test;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.ai.AiModel;
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
import org.thingsboard.server.dao.service.DaoSqlTest;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UpdateMsgType;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import java.util.Optional;
import java.util.UUID;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
public class AiModelEdgeTest extends AbstractEdgeTest {
private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel";
private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel";
@Test
public void testAiModel_create_update_delete() throws Exception {
// create AiModel
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
edgeImitator.expectMessageAmount(1);
AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class);
Assert.assertTrue(edgeImitator.waitForMessages());
AbstractMessage latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg);
AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage;
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType());
Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB());
Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB());
AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true);
Assert.assertNotNull(aiModelFromMsg);
Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName());
Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId());
// update AiModel
edgeImitator.expectMessageAmount(1);
savedAiModel.setName(UPDATED_AI_MODEL_NAME);
savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class);
Assert.assertTrue(edgeImitator.waitForMessages());
latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg);
aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage;
aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true);
Assert.assertNotNull(aiModelFromMsg);
Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType());
Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName());
// delete AiModel
edgeImitator.expectMessageAmount(1);
doDelete("/api/ai/model/" + savedAiModel.getUuidId())
.andExpect(status().isOk());
Assert.assertTrue(edgeImitator.waitForMessages());
latestMessage = edgeImitator.getLatestMessage();
Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg);
aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage;
Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType());
Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB());
Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB());
}
@Test
public void testSendAiModelToCloud() throws Exception {
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
UUID uuid = Uuids.timeBased();
UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE);
checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName());
}
@Test
public void testUpdateAiModelNameOnCloud() throws Exception {
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
UUID uuid = Uuids.timeBased();
UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE);
checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName());
aiModel.setName(UPDATED_AI_MODEL_NAME);
UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE);
checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName());
}
@Test
public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception {
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME);
edgeImitator.expectMessageAmount(1);
AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class);
Assert.assertTrue(edgeImitator.waitForMessages());
UUID uuid = Uuids.timeBased();
UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE);
edgeImitator.expectResponsesAmount(1);
edgeImitator.expectMessageAmount(1);
edgeImitator.sendUplinkMsg(uplinkMsg);
Assert.assertTrue(edgeImitator.waitForResponses());
Assert.assertTrue(edgeImitator.waitForMessages());
Optional<AiModelUpdateMsg> aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class);
Assert.assertTrue(aiModelUpdateMsgOpt.isPresent());
AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get();
AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true);
Assert.assertNotNull(aiModelFromMsg);
Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName());
Assert.assertNotEquals(savedAiModel.getUuidId(), uuid);
AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class);
Assert.assertNotNull(aiModelFromCloud);
Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName());
}
private AiModel createSimpleAiModel(String name) {
AiModel aiModel = new AiModel();
aiModel.setTenantId(tenantId);
aiModel.setName(name);
aiModel.setConfiguration(OpenAiChatModelConfig.builder()
.providerConfig(new OpenAiProviderConfig(null, "test-api-key"))
.modelId("gpt-4o")
.temperature(0.5)
.topP(0.3)
.frequencyPenalty(0.1)
.presencePenalty(0.2)
.maxOutputTokens(1000)
.timeoutSeconds(60)
.maxRetries(2)
.build());
return aiModel;
}
private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException {
UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder();
AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder();
aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits());
aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits());
aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel));
aiModelUpdateMsgBuilder.setMsgType(updateMsgType);
testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder);
uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build());
testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder);
return uplinkMsgBuilder.build();
}
private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception {
edgeImitator.expectResponsesAmount(1);
edgeImitator.sendUplinkMsg(uplinkMsg);
Assert.assertTrue(edgeImitator.waitForResponses());
UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg();
Assert.assertTrue(latestResponseMsg.getSuccess());
AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class);
Assert.assertNotNull(aiModel);
Assert.assertEquals(resourceTitle, aiModel.getName());
}
}

6
application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java

@ -29,6 +29,7 @@ import org.thingsboard.edge.rpc.EdgeGrpcClient;
import org.thingsboard.edge.rpc.EdgeRpcClient;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
@ -358,6 +359,11 @@ public class EdgeImitator {
result.add(saveDownlinkMsg(calculatedFieldUpdateMsg));
}
}
if (downlinkMsg.getAiModelUpdateMsgCount() > 0) {
for (AiModelUpdateMsg aiModelUpdateMsg : downlinkMsg.getAiModelUpdateMsgList()) {
result.add(saveDownlinkMsg(aiModelUpdateMsg));
}
}
if (downlinkMsg.hasEdgeConfiguration()) {
result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration()));
}

143
application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationArgumentEntryTest.java

@ -0,0 +1,143 @@
/**
* 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.script.api.tbel.TbelCfArg;
import org.thingsboard.script.api.tbel.TbelCfPropagationArg;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class PropagationArgumentEntryTest {
private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805"));
private final AssetId ENTITY_2_ID = new AssetId(UUID.fromString("7bd85073-ded5-414f-a2ef-bd56ad3dbf6a"));
private final AssetId ENTITY_3_ID = new AssetId(UUID.fromString("d64f3e51-2ec2-472f-b475-b095ef8bdc70"));
private PropagationArgumentEntry entry;
@BeforeEach
void setUp() {
List<EntityId> propagationEntityIds = new ArrayList<>();
propagationEntityIds.add(ENTITY_1_ID);
propagationEntityIds.add(ENTITY_2_ID);
entry = new PropagationArgumentEntry(propagationEntityIds);
}
@Test
void testArgumentEntryType() {
assertThat(entry.getType()).isEqualTo(ArgumentEntryType.PROPAGATION);
}
@Test
void testIsEmpty() {
PropagationArgumentEntry emptyEntry = new PropagationArgumentEntry(List.of());
assertThat(emptyEntry.isEmpty()).isTrue();
}
@Test
void testIsEmptyWhenNullList() {
PropagationArgumentEntry nullListEntry = new PropagationArgumentEntry(null);
assertThat(nullListEntry.isEmpty()).isTrue();
}
@Test
void testGetValueReturnsPropagationIds() {
assertThat(entry.getValue()).isInstanceOf(List.class);
@SuppressWarnings("unchecked")
List<AssetId> value = (List<AssetId>) entry.getValue();
assertThat(value).containsExactly(ENTITY_1_ID, ENTITY_2_ID);
}
@Test
void testUpdateEntryWhenSingleEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE");
}
@Test
void testUpdateEntryWhenRollingEntryPassed() {
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L)))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING");
}
@Test
void testUpdateEntryReplacesWithNewIds() {
var newIds = new ArrayList<EntityId>(List.of(ENTITY_3_ID, ENTITY_1_ID));
var updated = new PropagationArgumentEntry(newIds);
boolean changed = entry.updateEntry(updated);
assertThat(changed).isTrue();
assertThat(entry.getPropagationEntityIds()).containsExactlyElementsOf(newIds);
}
@Test
void testUpdateEntryClearsWhenNewEntryIsEmpty() {
var updatedEmpty = new PropagationArgumentEntry(List.of());
boolean changed = entry.updateEntry(updatedEmpty);
assertThat(changed).isTrue();
assertThat(entry.getPropagationEntityIds()).isEmpty();
}
@Test
void testUpdateEntryClearsWhenNewEntryIsNullList() {
var updatedNull = new PropagationArgumentEntry(null);
boolean changed = entry.updateEntry(updatedNull);
assertThat(changed).isTrue();
assertThat(entry.getPropagationEntityIds()).isEmpty();
}
@Test
@SuppressWarnings("unchecked")
void testToTbelCfArgWithValues() {
TbelCfArg arg = entry.toTbelCfArg();
assertThat(arg).isInstanceOf(TbelCfPropagationArg.class);
TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) arg;
assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class);
assertThat((List<EntityId>) tbelCfPropagationArg.getValue()).containsExactly(ENTITY_1_ID, ENTITY_2_ID);
}
@Test
@SuppressWarnings("unchecked")
void testToTbelCfArgWithEmptyValues() {
var empty = new PropagationArgumentEntry(List.of());
TbelCfArg emptyArg = empty.toTbelCfArg();
assertThat(emptyArg).isInstanceOf(TbelCfPropagationArg.class);
TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) emptyArg;
assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class);
assertThat((List<EntityId>) tbelCfPropagationArg.getValue()).isEmpty();
}
}

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

@ -0,0 +1,247 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.cf.ctx.state;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.script.api.tbel.DefaultTbelInvokeService;
import org.thingsboard.script.api.tbel.TbelInvokeService;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Output;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
import org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.id.AssetId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.stats.DefaultStatsFactory;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult;
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class})
public class PropagationCalculatedFieldStateTest {
private static final String TEMPERATURE_ARGUMENT_NAME = "t";
private static final String TEST_RESULT_EXPRESSION_KEY = "testResult";
private static final double TEMPERATURE_VALUE = 12.5;
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("6c3513cb-85e7-4510-8746-1ba01859a8ce"));
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("be960a50-c029-4698-b2ec-c56a543c561c"));
private final AssetId ASSET_ID_1 = new AssetId(UUID.fromString("d26f0e5b-7d7d-4a61-9f5e-08ab97b30734"));
private final AssetId ASSET_ID_2 = new AssetId(UUID.fromString("1933a317-4df5-4d36-9800-68aded74579b"));
private final SingleValueArgumentEntry singleValueArgEntry =
new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", TEMPERATURE_VALUE), 99L);
private final PropagationArgumentEntry propagationArgEntry =
new PropagationArgumentEntry(new ArrayList<>(List.of(ASSET_ID_2, ASSET_ID_1)));
private PropagationCalculatedFieldState state;
private CalculatedFieldCtx ctx;
@Autowired
private TbelInvokeService tbelInvokeService;
@MockitoBean
private ApiLimitService apiLimitService;
@MockitoBean
private ActorSystemContext actorSystemContext;
@BeforeEach
void setUp() {
when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService);
when(actorSystemContext.getApiLimitService()).thenReturn(apiLimitService);
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L);
}
void initCtxAndState(boolean applyExpressionToResolvedArguments) {
ctx = new CalculatedFieldCtx(getCalculatedField(applyExpressionToResolvedArguments), actorSystemContext);
ctx.init();
state = new PropagationCalculatedFieldState(ctx.getEntityId());
state.setCtx(ctx, null);
state.init();
}
@Test
void testType() {
initCtxAndState(false);
assertThat(state.getType()).isEqualTo(CalculatedFieldType.PROPAGATION);
}
@Test
void testInitAddsRequiredArgument() {
initCtxAndState(false);
assertThat(state.getRequiredArguments()).containsExactlyInAnyOrder(TEMPERATURE_ARGUMENT_NAME);
}
@Test
void testIsReadyReturnFalseWhenNoArgumentsSet() {
initCtxAndState(false);
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenPropagationArgIsNull() {
initCtxAndState(false);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenPropagationArgIsEmpty() {
initCtxAndState(false);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList()));
assertThat(state.isReady()).isFalse();
}
@Test
void testIsReadyWhenPropagationArgHasEntities() {
initCtxAndState(false);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry);
assertThat(state.isReady()).isTrue();
}
@Test
void testPerformCalculationWithEmptyPropagationArg() throws Exception {
initCtxAndState(false);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList()));
PropagationCalculatedFieldResult result = performCalculation();
assertThat(result).isNotNull();
assertThat(result.isEmpty()).isTrue();
assertThat(result.getPropagationEntityIds()).isNullOrEmpty();
}
@Test
void testPerformCalculationWithArgumentsOnlyMode() throws Exception {
initCtxAndState(false);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
PropagationCalculatedFieldResult propagationResult = performCalculation();
assertThat(propagationResult).isNotNull();
assertThat(propagationResult.isEmpty()).isFalse();
assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1);
TelemetryCalculatedFieldResult result = propagationResult.getResult();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES);
assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE);
ObjectNode expectedNode = JacksonUtil.newObjectNode();
JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue(), TEMPERATURE_ARGUMENT_NAME);
assertThat(result.getResult()).isEqualTo(expectedNode);
}
@Test
void testPerformCalculationWithExpressionResultMode() throws Exception {
initCtxAndState(true);
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry);
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry);
PropagationCalculatedFieldResult propagationResult = performCalculation();
assertThat(propagationResult).isNotNull();
assertThat(propagationResult.isEmpty()).isFalse();
assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1);
TelemetryCalculatedFieldResult result = propagationResult.getResult();
assertThat(result).isNotNull();
assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES);
assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE);
ObjectNode expectedNode = JacksonUtil.newObjectNode();
expectedNode.put(TEST_RESULT_EXPRESSION_KEY, TEMPERATURE_VALUE * 2);
assertThat(result.getResult()).isEqualTo(expectedNode);
}
private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) {
CalculatedField calculatedField = new CalculatedField();
calculatedField.setTenantId(TENANT_ID);
calculatedField.setEntityId(DEVICE_ID);
calculatedField.setType(CalculatedFieldType.PROPAGATION);
calculatedField.setName("Test Propagation CF");
calculatedField.setConfigurationVersion(1);
calculatedField.setConfiguration(getCalculatedFieldConfig(applyExpressionToResolvedArguments));
calculatedField.setVersion(1L);
return calculatedField;
}
private CalculatedFieldConfiguration getCalculatedFieldConfig(boolean applyExpressionToResolvedArguments) {
var config = new PropagationCalculatedFieldConfiguration();
config.setDirection(EntitySearchDirection.TO);
config.setRelationType(EntityRelation.CONTAINS_TYPE);
config.setApplyExpressionToResolvedArguments(applyExpressionToResolvedArguments);
Argument temperatureArg = new Argument();
ReferencedEntityKey tempKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null);
temperatureArg.setRefEntityKey(tempKey);
config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg));
config.setExpression("{" + TEST_RESULT_EXPRESSION_KEY + ": " + TEMPERATURE_ARGUMENT_NAME + " * 2}");
Output output = new Output();
output.setType(OutputType.ATTRIBUTES);
output.setScope(AttributeScope.SERVER_SCOPE);
config.setOutput(output);
return config;
}
private PropagationCalculatedFieldResult performCalculation() throws ExecutionException, InterruptedException {
return (PropagationCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get();
}
}

91
application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java

@ -27,7 +27,7 @@ import org.springframework.data.util.Pair;
import org.springframework.test.context.TestPropertySource;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.common.data.DataConstants;
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.EntityType;
@ -39,17 +39,19 @@ import org.thingsboard.server.common.data.alarm.AlarmCommentType;
import org.thingsboard.server.common.data.alarm.AlarmSearchStatus;
import org.thingsboard.server.common.data.alarm.AlarmSeverity;
import org.thingsboard.server.common.data.alarm.AlarmStatus;
import org.thingsboard.server.common.data.alarm.rule.AlarmRule;
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition;
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.cf.CalculatedField;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import org.thingsboard.server.common.data.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.device.profile.AlarmCondition;
import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter;
import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey;
import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType;
import org.thingsboard.server.common.data.device.profile.AlarmRule;
import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm;
import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.AlarmId;
import org.thingsboard.server.common.data.id.DeviceId;
@ -87,9 +89,6 @@ import org.thingsboard.server.common.data.notification.targets.platform.SystemAd
import org.thingsboard.server.common.data.notification.template.NotificationTemplate;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.query.BooleanFilterPredicate;
import org.thingsboard.server.common.data.query.EntityKeyValueType;
import org.thingsboard.server.common.data.query.FilterPredicateValue;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.data.rule.RuleChainMetaData;
import org.thingsboard.server.common.data.security.Authority;
@ -106,12 +105,10 @@ import org.thingsboard.server.service.system.DefaultSystemInfoService;
import org.thingsboard.server.service.telemetry.AlarmSubscriptionService;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
@ -193,7 +190,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
@Test
public void testNotificationRuleProcessing_alarmTrigger() throws Exception {
String notificationSubject = "Alarm type: ${alarmType}, status: ${alarmStatus}, " +
"severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}";
"severity: ${alarmSeverity}, deviceId: ${alarmOriginatorId}";
String notificationText = "Status: ${alarmStatus}, severity: ${alarmSeverity}";
NotificationTemplate notificationTemplate = createNotificationTemplate(NotificationType.ALARM, notificationSubject, notificationText, NotificationDeliveryMethod.WEB);
@ -234,8 +231,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
});
JsonNode attr = JacksonUtil.newObjectNode()
.set("bool", BooleanNode.TRUE);
doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr);
.set("createAlarm", BooleanNode.TRUE);
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString());
await().atMost(10, TimeUnit.SECONDS)
.until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null);
@ -250,7 +247,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
assertThat(actualDelay).isCloseTo(expectedDelay, offset(2.0));
assertThat(notification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + AlarmStatus.ACTIVE_UNACK + ", " +
"severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId());
"severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase() + ", deviceId: " + device.getId());
assertThat(notification.getText()).isEqualTo("Status: " + AlarmStatus.ACTIVE_UNACK + ", severity: " + AlarmSeverity.CRITICAL.toString().toLowerCase());
assertThat(notification.getType()).isEqualTo(NotificationType.ALARM);
@ -270,7 +267,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
wsClient.waitForUpdate(true);
Notification updatedNotification = wsClient.getLastDataUpdate().getUpdate();
assertThat(updatedNotification.getSubject()).isEqualTo("Alarm type: " + alarmType + ", status: " + expectedStatus + ", " +
"severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId());
"severity: " + expectedSeverity.toString().toLowerCase() + ", deviceId: " + device.getId());
assertThat(updatedNotification.getText()).isEqualTo("Status: " + expectedStatus + ", severity: " + expectedSeverity.toString().toLowerCase());
wsClient.close();
@ -296,7 +293,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
List<Notification> notifications = getMyNotifications(false, 10);
assertThat(notifications).singleElement().matches(notification -> {
return notification.getType() == NotificationType.ALARM &&
notification.getSubject().equals("New alarm 'testAlarm'");
notification.getSubject().equals("New alarm 'testAlarm'");
});
});
}
@ -341,8 +338,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
getWsClient().subscribeForUnreadNotifications(10).waitForReply(true);
getWsClient().registerWaitForUpdate();
JsonNode attr = JacksonUtil.newObjectNode()
.set("bool", BooleanNode.TRUE);
doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, attr);
.set("createAlarm", BooleanNode.TRUE);
postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString());
await().atMost(10, TimeUnit.SECONDS)
.until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null);
@ -491,11 +488,11 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
});
assertThat(notifications).anySatisfy(notification -> {
assertThat(notification.getText()).isEqualTo("Rate limits for REST API requests per customer " +
"exceeded for 'Customer'");
"exceeded for 'Customer'");
});
assertThat(notifications).anySatisfy(notification -> {
assertThat(notification.getText()).isEqualTo("Rate limits for notification requests " +
"per rule exceeded for '" + rule.getName() + "'");
"per rule exceeded for '" + rule.getName() + "'");
});
loginSysAdmin();
@ -748,7 +745,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
.build();
assertThat(DefaultNotificationDeduplicationService.getDeduplicationKey(expectedTrigger, rule))
.isEqualTo("RATE_LIMITS:TENANT:" + tenantId + ":ENTITY_EXPORT_" +
target.getId() + ":ENTITY_EXPORT,TRANSPORT_MESSAGES_PER_DEVICE");
target.getId() + ":ENTITY_EXPORT,TRANSPORT_MESSAGES_PER_DEVICE");
loginTenantAdmin();
getWsClient().subscribeForUnreadNotifications(10).waitForReply();
@ -944,35 +941,27 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest {
private DeviceProfile createDeviceProfileWithAlarmRules(String alarmType) {
DeviceProfile deviceProfile = createDeviceProfile("For notification rule test");
deviceProfile.setTenantId(tenantId);
deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
List<DeviceProfileAlarm> alarms = new ArrayList<>();
DeviceProfileAlarm alarm = new DeviceProfileAlarm();
alarm.setAlarmType(alarmType);
alarm.setId(alarmType);
CalculatedField alarmCf = new CalculatedField();
alarmCf.setType(CalculatedFieldType.ALARM);
alarmCf.setEntityId(deviceProfile.getId());
alarmCf.setName(alarmType);
AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration();
Argument argument = new Argument();
argument.setRefEntityKey(new ReferencedEntityKey("createAlarm", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE));
configuration.setArguments(Map.of("createAlarm", argument));
AlarmRule alarmRule = new AlarmRule();
alarmRule.setAlarmDetails("Details");
AlarmCondition alarmCondition = new AlarmCondition();
alarmCondition.setSpec(new SimpleAlarmConditionSpec());
List<AlarmConditionFilter> condition = new ArrayList<>();
AlarmConditionFilter alarmConditionFilter = new AlarmConditionFilter();
alarmConditionFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "bool"));
BooleanFilterPredicate predicate = new BooleanFilterPredicate();
predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL);
predicate.setValue(new FilterPredicateValue<>(true));
alarmConditionFilter.setPredicate(predicate);
alarmConditionFilter.setValueType(EntityKeyValueType.BOOLEAN);
condition.add(alarmConditionFilter);
alarmCondition.setCondition(condition);
alarmRule.setCondition(alarmCondition);
TreeMap<AlarmSeverity, AlarmRule> createRules = new TreeMap<>();
createRules.put(AlarmSeverity.CRITICAL, alarmRule);
alarm.setCreateRules(createRules);
alarms.add(alarm);
deviceProfile.getProfileData().setAlarms(alarms);
deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class);
SimpleAlarmCondition condition = new SimpleAlarmCondition();
TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression();
expression.setExpression("return createAlarm == true;");
condition.setExpression(expression);
alarmRule.setCondition(condition);
configuration.setCreateRules(Map.of(
AlarmSeverity.CRITICAL, alarmRule
));
alarmCf.setConfiguration(configuration);
saveCalculatedField(alarmCf);
return deviceProfile;
}

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

@ -26,22 +26,28 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.JsonDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto;
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState;
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@ExtendWith(MockitoExtension.class)
@ -87,11 +93,9 @@ class CalculatedFieldUtilsTest {
CalculatedFieldState state = new GeofencingCalculatedFieldState(DEVICE_ID);
state.update(Map.of("geofencingArgumentTest", geofencingArgumentEntry), mock(CalculatedFieldCtx.class));
// when
CalculatedFieldStateProto proto = toProto(stateId, state);
// then
CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(stateId, proto);
assertThat(fromProto)
.usingRecursiveComparison()
.ignoringFields("requiredArguments")
@ -105,4 +109,41 @@ class CalculatedFieldUtilsTest {
assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull();
}
@Test
void toProtoAndFromProto_shouldCreatePropagationStateWithoutPropagationArgument() {
// given
CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class);
given(stateId.tenantId()).willReturn(TENANT_ID);
given(stateId.cfId()).willReturn(CF_ID);
given(stateId.entityId()).willReturn(DEVICE_ID);
AssetId propagationAssetId = new AssetId(UUID.fromString("17bbf99c-3b87-4d21-b07d-da7409bb2bb7"));
PropagationArgumentEntry propagationArgumentEntry = new PropagationArgumentEntry(List.of(propagationAssetId));
long lastUpdateTs = System.currentTimeMillis();
SingleValueArgumentEntry singleValueArgumentEntry = new SingleValueArgumentEntry(new BaseAttributeKvEntry(new StringDataEntry("state", "active"), lastUpdateTs, 1L));
CalculatedFieldCtx cfCtxMock = mock(CalculatedFieldCtx.class);
CalculatedFieldState state = new PropagationCalculatedFieldState(DEVICE_ID);
state.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, propagationArgumentEntry, "state", singleValueArgumentEntry), cfCtxMock);
// when
CalculatedFieldStateProto proto = toProto(stateId, state);
// then
CalculatedFieldState restored = CalculatedFieldUtils.fromProto(stateId, proto);
// Propagation argument is not persisted -> should be absent after restore
assertThat(restored).isNotNull();
assertThat(restored).isInstanceOf(PropagationCalculatedFieldState.class);
PropagationCalculatedFieldState propagationState = (PropagationCalculatedFieldState) restored;
assertThat(propagationState.getEntityId()).isEqualTo(DEVICE_ID);
assertThat(propagationState.getArguments()).isNotNull();
assertThat(propagationState.getArguments().get(PROPAGATION_CONFIG_ARGUMENT)).isNull();
assertThat(propagationState.getArguments().get("state")).isNotNull().isEqualTo(singleValueArgumentEntry);
}
}

2
application/src/test/resources/logback-test.xml

@ -17,6 +17,8 @@
<logger name="org.eclipse.leshan" level="INFO"/>
<logger name="org.thingsboard.server.controller.AbstractWebTest" level="INFO"/>
<logger name="org.thingsboard.server.service.script" level="INFO"/>
<logger name="org.thingsboard.server.service.cf.ctx.state.alarm" level="TRACE"/>
<logger name="org.thingsboard.server.actors.calculatedField" level="TRACE"/>
<!-- mute TelemetryEdgeSqlTest that causes a lot of randomly generated errors -->
<logger name="org.thingsboard.server.service.edge.rpc.PostgresEdgeGrpcSession" level="OFF"/>

4
common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java

@ -29,6 +29,8 @@ public interface AiModelService extends EntityDaoService {
AiModel save(AiModel model);
AiModel save(AiModel model, boolean doValidate);
Optional<AiModel> findAiModelById(TenantId tenantId, AiModelId modelId);
PageData<AiModel> findAiModelsByTenantId(TenantId tenantId, PageLink pageLink);
@ -37,6 +39,8 @@ public interface AiModelService extends EntityDaoService {
FluentFuture<Optional<AiModel>> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId);
Optional<AiModel> findAiModelByTenantIdAndName(TenantId tenantId, String name);
boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId);
}

3
common/dao-api/src/main/java/org/thingsboard/server/dao/asset/AssetService.java

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.asset;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.asset.Asset;
import org.thingsboard.server.common.data.asset.AssetInfo;
@ -48,6 +49,8 @@ public interface AssetService extends EntityDaoService {
Asset saveAsset(Asset asset);
Asset saveAsset(Asset asset, NameConflictStrategy nameConflictStrategy);
Asset assignAssetToCustomer(TenantId tenantId, AssetId assetId, CustomerId customerId);
Asset unassignAssetFromCustomer(TenantId tenantId, AssetId assetId);

3
common/dao-api/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java

@ -17,6 +17,7 @@ package org.thingsboard.server.dao.customer;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
@ -37,6 +38,8 @@ public interface CustomerService extends EntityDaoService {
Customer saveCustomer(Customer customer);
Customer saveCustomer(Customer customer, NameConflictStrategy nameConflictStrategy);
void deleteCustomer(TenantId tenantId, CustomerId customerId);
Customer findOrCreatePublicCustomer(TenantId tenantId);

5
common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java

@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.DeviceInfoFilter;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceTransportType;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.device.DeviceSearchQuery;
import org.thingsboard.server.common.data.id.CustomerId;
@ -58,8 +59,12 @@ public interface DeviceService extends EntityDaoService {
Device saveDeviceWithAccessToken(Device device, String accessToken);
Device saveDeviceWithAccessToken(Device device, String accessToken, NameConflictStrategy nameConflictStrategy);
Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials);
Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy);
Device saveDevice(ProvisionRequest provisionRequest, DeviceProfile profile);
Device assignDeviceToCustomer(TenantId tenantId, DeviceId deviceId, CustomerId customerId);

3
common/dao-api/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java

@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.EntityViewInfo;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EdgeId;
@ -38,6 +39,8 @@ public interface EntityViewService extends EntityDaoService {
EntityView saveEntityView(EntityView entityView);
EntityView saveEntityView(EntityView entityView, NameConflictStrategy nameConflictStrategy);
EntityView saveEntityView(EntityView entityView, boolean doValidate);
EntityView assignEntityViewToCustomer(TenantId tenantId, EntityViewId entityViewId, CustomerId customerId);

23
common/data/src/main/java/org/thingsboard/server/common/data/NameConflictPolicy.java

@ -0,0 +1,23 @@
/**
* 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;
public enum NameConflictPolicy {
FAIL,
UNIQUIFY;
}

25
common/data/src/main/java/org/thingsboard/server/common/data/NameConflictStrategy.java

@ -0,0 +1,25 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema
public record NameConflictStrategy(NameConflictPolicy policy, String separator, UniquifyStrategy uniquifyStrategy) {
public static final NameConflictStrategy DEFAULT = new NameConflictStrategy(NameConflictPolicy.FAIL, null, null);
}

23
common/data/src/main/java/org/thingsboard/server/common/data/UniquifyStrategy.java

@ -0,0 +1,23 @@
/**
* 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;
public enum UniquifyStrategy {
RANDOM,
INCREMENTAL;
}

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

@ -25,6 +25,7 @@ public enum CalculatedFieldType {
SCRIPT,
GEOFENCING,
ALARM,
PROPAGATION,
LATEST_VALUES_AGGREGATION;
public static final Set<CalculatedFieldType> all = Collections.unmodifiableSet(EnumSet.allOf(CalculatedFieldType.class));

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

@ -28,8 +28,6 @@ import java.util.Map;
@Data
public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration {
@Valid
@NotEmpty
private Map<String, Argument> arguments;
@Valid

7
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java

@ -15,6 +15,8 @@
*/
package org.thingsboard.server.common.data.cf.configuration;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import org.thingsboard.server.common.data.id.EntityId;
import java.util.List;
@ -24,9 +26,14 @@ import java.util.stream.Collectors;
public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
@Valid
@NotEmpty
Map<String, Argument> getArguments();
default List<EntityId> getReferencedEntities() {
if (getArguments() == null) {
return List.of();
}
return getArguments().values().stream()
.map(Argument::getRefEntityId)
.filter(Objects::nonNull)

10
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java

@ -28,12 +28,16 @@ public abstract class BaseCalculatedFieldConfiguration implements ExpressionBase
@Override
public void validate() {
baseCalculatedFieldRestriction();
if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query configuration!");
}
}
protected void baseCalculatedFieldRestriction() {
if (arguments.containsKey("ctx")) {
throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used.");
}
if (arguments.values().stream().anyMatch(Argument::hasRelationQuerySource)) {
throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' doesn't support relation query source configuration!");
}
}
}

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

@ -28,7 +28,6 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@ -42,6 +41,7 @@ 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 = LatestValuesAggregationCalculatedFieldConfiguration.class, name = "LATEST_VALUES_AGGREGATION")
})
@JsonIgnoreProperties(ignoreUnknown = true)
@ -56,7 +56,7 @@ public interface CalculatedFieldConfiguration {
@JsonIgnore
default List<EntityId> getReferencedEntities() {
return Collections.emptyList();
return List.of();
}
default CalculatedFieldLink buildCalculatedFieldLink(TenantId tenantId, EntityId referencedEntityId, CalculatedFieldId calculatedFieldId) {

96
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java

@ -0,0 +1,96 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import org.thingsboard.server.common.data.relation.RelationPathLevel;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class PropagationCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration {
public static final String PROPAGATION_CONFIG_ARGUMENT = "propagationCtx";
@NotNull
private EntitySearchDirection direction;
@NotBlank
private String relationType;
private boolean applyExpressionToResolvedArguments;
@Override
public CalculatedFieldType getType() {
return CalculatedFieldType.PROPAGATION;
}
@Override
public void validate() {
baseCalculatedFieldRestriction();
propagationRestriction();
if (!applyExpressionToResolvedArguments) {
arguments.forEach((name, argument) -> {
if (!currentEntitySource(argument)) {
throw new IllegalArgumentException("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!");
}
if (argument.getRefEntityKey() == null) {
throw new IllegalArgumentException("Argument: '" + name + "' doesn't have reference entity key configured!");
}
if (argument.getRefEntityKey().getType() == ArgumentType.TS_ROLLING) {
throw new IllegalArgumentException("Argument type: 'Time series rolling' detected for argument: '" + name + "'. " +
"Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!");
}
});
} else {
boolean noneMatchCurrentEntitySource = arguments.entrySet()
.stream()
.noneMatch(entry -> currentEntitySource(entry.getValue()));
if (noneMatchCurrentEntitySource) {
throw new IllegalArgumentException("At least one argument must be configured with the 'Current entity' " +
"source entity type for 'Expression result' propagation mode!");
}
if (StringUtils.isBlank(expression)) {
throw new IllegalArgumentException("Expression must be specified for 'Expression result' propagation mode!");
}
}
}
public Argument toPropagationArgument() {
var refDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration();
refDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(direction, relationType)));
var propagationArgument = new Argument();
propagationArgument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration);
return propagationArgument;
}
private void propagationRestriction() {
if (arguments.entrySet().stream().anyMatch(entry -> entry.getKey().equals(PROPAGATION_CONFIG_ARGUMENT))) {
throw new IllegalArgumentException("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used.");
}
}
private boolean currentEntitySource(Argument argument) {
return argument.getRefEntityId() == null && argument.getRefDynamicSourceConfiguration() == null;
}
}

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

@ -15,10 +15,13 @@
*/
package org.thingsboard.server.common.data.cf.configuration;
import jakarta.validation.constraints.PositiveOrZero;
public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration {
boolean isScheduledUpdateEnabled();
@PositiveOrZero
int getScheduledUpdateInterval();
void setScheduledUpdateInterval(int interval);

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

@ -16,8 +16,8 @@
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
@ -30,18 +30,11 @@ public class EntityCoordinates {
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
@NotBlank
private final String latitudeKeyName;
@NotBlank
private final String longitudeKeyName;
public void validate() {
if (StringUtils.isBlank(latitudeKeyName)) {
throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!");
}
if (StringUtils.isBlank(longitudeKeyName)) {
throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!");
}
}
public Map<String, Argument> toArguments() {
return Map.of(
ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName),

16
common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java

@ -16,6 +16,8 @@
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
@ -32,7 +34,12 @@ import java.util.Objects;
@Data
public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration {
@Valid
@NotNull
private EntityCoordinates entityCoordinates;
@Valid
@NotNull
private Map<String, ZoneGroupConfiguration> zoneGroups;
private boolean scheduledUpdateEnabled;
@ -56,7 +63,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal
@Override
public List<EntityId> getReferencedEntities() {
return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList();
return zoneGroups == null ? List.of() : zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList();
}
@Override
@ -66,13 +73,6 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal
@Override
public void validate() {
if (entityCoordinates == null) {
throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!");
}
entityCoordinates.validate();
if (zoneGroups == null || zoneGroups.isEmpty()) {
throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
zoneGroups.forEach((key, value) -> value.validate(key));
}

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

@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.lang.Nullable;
import org.thingsboard.server.common.data.AttributeScope;
@ -36,8 +38,10 @@ public class ZoneGroupConfiguration {
private EntityId refEntityId;
private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration;
@NotBlank
private final String perimeterKeyName;
@NotNull
private final GeofencingReportStrategy reportStrategy;
private final boolean createRelationsWithMatchedZones;
@ -48,12 +52,6 @@ public class ZoneGroupConfiguration {
if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) {
throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!");
}
if (StringUtils.isBlank(perimeterKeyName)) {
throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!");
}
if (reportStrategy == null) {
throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!");
}
if (refDynamicSourceConfiguration != null) {
refDynamicSourceConfiguration.validate();
}

3
common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java

@ -47,7 +47,8 @@ public enum EdgeEventType {
TB_RESOURCE(true, EntityType.TB_RESOURCE),
OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT),
DOMAIN(true, EntityType.DOMAIN),
CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD);
CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD),
AI_MODEL(true, EntityType.AI_MODEL);
private final boolean allEdgesRelated;

1
common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java

@ -113,6 +113,7 @@ public class EntityIdFactory {
case OAUTH2_CLIENT -> new OAuth2ClientId(uuid);
case DOMAIN -> new DomainId(uuid);
case CALCULATED_FIELD -> new CalculatedFieldId(uuid);
case AI_MODEL -> new AiModelId(uuid);
default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!");
};
}

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

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

12
common/data/src/main/java/org/thingsboard/server/common/data/util/TypeCastUtil.java

@ -15,6 +15,7 @@
*/
package org.thingsboard.server.common.data.util;
import com.google.gson.JsonParser;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.lang3.tuple.Pair;
import org.thingsboard.server.common.data.kv.DataType;
@ -40,6 +41,11 @@ public class TypeCastUtil {
} catch (RuntimeException ignored) {}
} else if (value.equalsIgnoreCase("true") || value.equalsIgnoreCase("false")) {
return Pair.of(DataType.BOOLEAN, Boolean.parseBoolean(value));
} else if (looksLikeJson(value)) {
try {
return Pair.of(DataType.JSON, JsonParser.parseString(value));
} catch (Exception ignored) {
}
}
return Pair.of(DataType.STRING, value);
}
@ -70,4 +76,10 @@ public class TypeCastUtil {
return valueAsString.contains(".") && !valueAsString.contains("E") && !valueAsString.contains("e");
}
private static boolean looksLikeJson(String value) {
String trimmed = value.trim();
return (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"));
}
}

153
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfigurationTest.java

@ -0,0 +1,153 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.cf.configuration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.EntitySearchDirection;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT;
@ExtendWith(MockitoExtension.class)
public class PropagationCalculatedFieldConfigurationTest {
@Test
void typeShouldBePropagation() {
var cfg = new PropagationCalculatedFieldConfiguration();
assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.PROPAGATION);
}
@Test
void validateShouldThrowWhenConfigurationDisallowArgumentsWithReferencedEntity() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argumentWithRefEntityIdSet = new Argument();
argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("bda14084-f40e-4acc-9b85-9d1dd209bb64")));
cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!");
}
@Test
void validateShouldThrowWhenConfigurationDisallowArgumentsWithDynamicReferenceConfiguration() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argumentWithDynamicRefEntitySource = new Argument();
argumentWithDynamicRefEntitySource.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration());
cfg.setArguments(Map.of("argumentWithDynamicRefEntitySource", argumentWithDynamicRefEntitySource));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Arguments in 'Arguments only' propagation mode support only the 'Current entity' source entity type!");
}
@Test
void validateShouldThrowWhenConfigurationHasNoArgumentsWithCurrentEntitySource() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argumentWithRefEntityIdSet = new Argument();
argumentWithRefEntityIdSet.setRefEntityId(new DeviceId(UUID.fromString("3703e895-3f9b-4b75-a715-b68f1ad51944")));
cfg.setArguments(Map.of("argumentWithRefEntityIdSet", argumentWithRefEntityIdSet));
cfg.setApplyExpressionToResolvedArguments(true);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("At least one argument must be configured with the 'Current entity' " +
"source entity type for 'Expression result' propagation mode!");
}
@Test
void validateShouldThrowWhenUsedReservedPropagationArgumentName() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setArguments(Map.of(PROPAGATION_CONFIG_ARGUMENT, new Argument()));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument name '" + PROPAGATION_CONFIG_ARGUMENT + "' is reserved and cannot be used.");
}
@Test
void validateShouldThrowWhenUsedReservedCtxArgumentName() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("ctx", new Argument()));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument name 'ctx' is reserved and cannot be used.");
}
@Test
void validateShouldThrowWhenReferencedEntityKeyIsNotSet() {
var cfg = new PropagationCalculatedFieldConfiguration();
Argument argument = new Argument();
cfg.setArguments(Map.of("someArgumentName", argument));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument: 'someArgumentName' doesn't have reference entity key configured!");
}
@Test
void validateShouldThrowWhenReferencedEntityKeyTypeIsTsRolling() {
var cfg = new PropagationCalculatedFieldConfiguration();
ReferencedEntityKey referencedEntityKey = new ReferencedEntityKey("someKey", ArgumentType.TS_ROLLING, null);
Argument argument = new Argument();
argument.setRefEntityKey(referencedEntityKey);
cfg.setArguments(Map.of("someArgumentName", argument));
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Argument type: 'Time series rolling' detected for argument: 'someArgumentName'. " +
"Only 'Attribute' or 'Latest telemetry' arguments are allowed for 'Arguments only' propagation mode!");
}
@Test
void validateShouldThrowWhenExpressionIsNotSet() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setArguments(Map.of("someArgumentName", new Argument()));
cfg.setApplyExpressionToResolvedArguments(true);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Expression must be specified for 'Expression result' propagation mode!");
}
@Test
void validateToPropagationArgumentMethodCallReturnCorrectArgument() {
var cfg = new PropagationCalculatedFieldConfiguration();
cfg.setDirection(EntitySearchDirection.TO);
cfg.setRelationType(EntityRelation.CONTAINS_TYPE);
Argument propagationArgument = cfg.toPropagationArgument();
assertThat(propagationArgument).isNotNull();
assertThat(propagationArgument.getRefEntityId()).isNull();
assertThat(propagationArgument.getRefEntityKey()).isNull();
assertThat(propagationArgument.getDefaultValue()).isNull();
assertThat(propagationArgument.getTimeWindow()).isNull();
assertThat(propagationArgument.getLimit()).isNull();
assertThat(propagationArgument.getRefDynamicSourceConfiguration())
.isNotNull()
.isInstanceOf(RelationPathQueryDynamicSourceConfiguration.class);
var refDynamicSourceConfiguration = (RelationPathQueryDynamicSourceConfiguration) propagationArgument.getRefDynamicSourceConfiguration();
assertThat(refDynamicSourceConfiguration.getLevels()).isNotEmpty().hasSize(1);
var relationPathLevel = refDynamicSourceConfiguration.getLevels().get(0);
assertThat(relationPathLevel.direction()).isEqualTo(EntitySearchDirection.TO);
assertThat(relationPathLevel.relationType()).isEqualTo(EntityRelation.CONTAINS_TYPE);
}
}

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

@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest {
@Test
void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() {
void validateDoesNotThrowAnyExceptionWhenScheduledUpdateIntervalIsGreaterThanMinAllowedIntervalInTenantProfile() {
int scheduledUpdateInterval = 60;
int minAllowedInterval = scheduledUpdateInterval - 1;

31
common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/EntityCoordinatesTest.java

@ -16,47 +16,16 @@
package org.thingsboard.server.common.data.cf.configuration.geofencing;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.thingsboard.server.common.data.cf.configuration.Argument;
import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
public class EntityCoordinatesTest {
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) {
var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude");
assertThatThrownBy(entityCoordinates::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity coordinates latitude key name must be specified!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) {
var entityCoordinates = new EntityCoordinates("latitude", longitudeKey);
assertThatThrownBy(entityCoordinates::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Entity coordinates longitude key name must be specified!");
}
@Test
void validateShouldPassOnMinimalValidConfig() {
var entityCoordinates = new EntityCoordinates("latitude", "longitude");
assertThatCode(entityCoordinates::validate).doesNotThrowAnyException();
}
@Test
void validateToArgumentsMethodCallWithoutRefEntityId() {
var entityCoordinates = new EntityCoordinates("xPos", "yPos");

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

@ -28,7 +28,6 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
@ -44,28 +43,7 @@ public class GeofencingCalculatedFieldConfigurationTest {
}
@Test
void validateShouldThrowWhenEntityCoordinatesNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field entity coordinates must be specified!");
}
@Test
void validateShouldThrowWhenZoneGroupsNull() {
var cfg = new GeofencingCalculatedFieldConfiguration();
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY));
cfg.setZoneGroups(null);
assertThatThrownBy(cfg::validate)
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!");
}
@Test
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() {
void validateShouldCallValidateOnZoneGroups() {
var cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
@ -73,13 +51,11 @@ public class GeofencingCalculatedFieldConfigurationTest {
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration));
cfg.validate();
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfiguration).validate("someGroupName");
}
@Test
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() {
void validateShouldCallValidateOnZoneGroupsWithoutAnyExceptions() {
var cfg = new GeofencingCalculatedFieldConfiguration();
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class);
cfg.setEntityCoordinates(entityCoordinatesMock);
@ -93,7 +69,6 @@ public class GeofencingCalculatedFieldConfigurationTest {
assertThatCode(cfg::validate).doesNotThrowAnyException();
verify(entityCoordinatesMock).validate();
verify(zoneGroupConfigurationA).validate(zoneGroupAName);
verify(zoneGroupConfigurationB).validate(zoneGroupBName);
}

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

@ -45,24 +45,6 @@ public class ZoneGroupConfigurationTest {
.hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource
void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) {
var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!");
}
@Test
void validateShouldThrowWhenReportStrategyIsNull() {
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false);
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!");
}
@ParameterizedTest
@ValueSource(strings = " ")
@NullAndEmptySource

2
common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java

@ -136,7 +136,7 @@ public class EdgeGrpcClient implements EdgeRpcClient {
.setConnectRequestMsg(ConnectRequestMsg.newBuilder()
.setEdgeRoutingKey(edgeKey)
.setEdgeSecret(edgeSecret)
.setEdgeVersion(EdgeVersion.V_4_2_0)
.setEdgeVersion(EdgeVersion.V_4_3_0)
.setMaxInboundMessageSize(maxInboundMessageSize)
.build())
.build());

9
common/edge-api/src/main/proto/edge.proto

@ -44,6 +44,7 @@ enum EdgeVersion {
V_4_0_0 = 10;
V_4_1_0 = 11;
V_4_2_0 = 12;
V_4_3_0 = 13;
V_LATEST = 999;
}
@ -133,6 +134,12 @@ message CalculatedFieldUpdateMsg{
string entity = 4;
}
message AiModelUpdateMsg{
UpdateMsgType msgType = 1;
int64 idMSB = 2;
int64 idLSB = 3;
string entity = 4;
}
message EntityDataProto {
int64 entityIdMSB = 1;
@ -441,6 +448,7 @@ message UplinkMsg {
repeated RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg = 24;
repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 25;
repeated CalculatedFieldRequestMsg calculatedFieldRequestMsg = 26;
repeated AiModelUpdateMsg aiModelUpdateMsg = 27;
}
message UplinkResponseMsg {
@ -491,4 +499,5 @@ message DownlinkMsg {
repeated NotificationTemplateUpdateMsg notificationTemplateUpdateMsg = 33;
repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34;
repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35;
repeated AiModelUpdateMsg aiModelUpdateMsg = 36;
}

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

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

4
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsGeofencingArg.java → common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfGeofencingArg.java

@ -20,12 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class TbelCfTsGeofencingArg implements TbelCfArg {
public class TbelCfGeofencingArg implements TbelCfArg {
private final Object value;
@JsonCreator
public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) {
public TbelCfGeofencingArg(@JsonProperty("value") Object value) {
this.value = value;
}

42
common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfPropagationArg.java

@ -0,0 +1,42 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.script.api.tbel;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
@Data
public class TbelCfPropagationArg implements TbelCfArg {
private final Object value;
@JsonCreator
public TbelCfPropagationArg(@JsonProperty("value") Object value) {
this.value = value;
}
@Override
public String getType() {
return "PROPAGATION_CF_ARGUMENT_VALUE";
}
@Override
public long memorySize() {
return OBJ_SIZE;
}
}

5
dao/src/main/java/org/thingsboard/server/dao/Dao.java

@ -16,6 +16,7 @@
package org.thingsboard.server.dao;
import com.google.common.util.concurrent.ListenableFuture;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.edqs.fields.EntityFields;
import org.thingsboard.server.common.data.id.TenantId;
@ -32,6 +33,10 @@ public interface Dao<T> {
ListenableFuture<T> findByIdAsync(TenantId tenantId, UUID id);
default List<EntityInfo> findEntityInfosByNamePrefix(TenantId tenantId, String name) {
throw new UnsupportedOperationException();
}
boolean existsById(TenantId tenantId, UUID id);
ListenableFuture<Boolean> existsByIdAsync(TenantId tenantId, UUID id);

41
dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java

@ -29,12 +29,15 @@ import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.dao.entity.CachedVersionedEntityService;
import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent;
import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent;
import org.thingsboard.server.dao.model.sql.AiModelEntity;
import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.thingsboard.server.dao.service.Validator.validatePageLink;
@ -63,11 +66,23 @@ class AiModelServiceImpl extends CachedVersionedEntityService<AiModelCacheKey, A
@Override
@Transactional
public AiModel save(AiModel model) {
aiModelValidator.validate(model, AiModel::getTenantId);
return save(model, true);
}
@Override
public AiModel save(AiModel aiModel, boolean doValidate) {
AiModel oldAiModel = null;
if (doValidate) {
oldAiModel = aiModelValidator.validate(aiModel, AiModel::getTenantId);
} else if (aiModel.getId() != null) {
oldAiModel = findAiModelById(aiModel.getTenantId(), aiModel.getId()).orElse(null);
}
AiModel savedModel;
try {
savedModel = aiModelDao.saveAndFlush(model.getTenantId(), model);
savedModel = aiModelDao.saveAndFlush(aiModel.getTenantId(), aiModel);
eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedModel.getTenantId()).entityId(savedModel.getId())
.entity(savedModel).oldEntity(oldAiModel).created(oldAiModel == null).broadcastEvent(true).build());
} catch (Exception e) {
checkConstraintViolation(e,
"ai_model_name_unq_key", "AI model with such name already exist!",
@ -103,10 +118,15 @@ class AiModelServiceImpl extends CachedVersionedEntityService<AiModelCacheKey, A
return FluentFuture.from(jpaExecutor.submit(() -> findAiModelByTenantIdAndId(tenantId, modelId)));
}
@Override
public Optional<AiModel> findAiModelByTenantIdAndName(TenantId tenantId, String name) {
return Optional.ofNullable(aiModelDao.findByTenantIdAndName(tenantId.getId(), name));
}
@Override
@Transactional
public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) {
return deleteByTenantIdAndIdInternal(tenantId, modelId);
return deleteByTenantIdAndIdInternal(tenantId, modelId.getId());
}
@Override
@ -123,14 +143,21 @@ class AiModelServiceImpl extends CachedVersionedEntityService<AiModelCacheKey, A
@Override
@Transactional
public void deleteEntity(TenantId tenantId, EntityId id, boolean force) {
deleteByTenantIdAndIdInternal(tenantId, new AiModelId(id.getId()));
deleteByTenantIdAndIdInternal(tenantId, id.getId());
}
private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelId modelId) {
boolean deleted = aiModelDao.deleteByTenantIdAndId(tenantId, modelId);
private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, UUID modelId) {
AiModel aiModel = findAiModelById(tenantId, new AiModelId(modelId)).orElse(null);
if (aiModel == null) {
return false;
}
boolean deleted = aiModelDao.deleteByTenantIdAndId(tenantId, aiModel.getId());
if (deleted) {
publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, modelId)));
publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, aiModel.getId())));
eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(aiModel.getId()).entity(aiModel).build());
}
return deleted;
}

20
dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java

@ -26,6 +26,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.asset.Asset;
@ -146,14 +148,24 @@ public class BaseAssetService extends AbstractCachedEntityService<AssetCacheKey,
return saveAsset(asset, true);
}
@Override
public Asset saveAsset(Asset asset, NameConflictStrategy nameConflictStrategy) {
return saveAsset(asset, true, nameConflictStrategy);
}
@Override
public Asset saveAsset(Asset asset, boolean doValidate) {
return saveAsset(asset, doValidate, NameConflictStrategy.DEFAULT);
}
private Asset saveAsset(Asset asset, boolean doValidate, NameConflictStrategy nameConflictStrategy) {
log.trace("Executing saveAsset [{}]", asset);
Asset oldAsset = null;
Asset oldAsset = (asset.getId() != null) ? assetDao.findById(asset.getTenantId(), asset.getId().getId()) : null;
if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (oldAsset == null || !oldAsset.getName().equals(asset.getName()))) {
uniquifyEntityName(asset, oldAsset, asset::setName, EntityType.ASSET, nameConflictStrategy);
}
if (doValidate) {
oldAsset = assetValidator.validate(asset, Asset::getTenantId);
} else if (asset.getId() != null) {
oldAsset = findAssetById(asset.getTenantId(), asset.getId());
assetValidator.validate(asset, Asset::getTenantId);
}
AssetCacheEvictEvent evictEvent = new AssetCacheEvictEvent(asset.getTenantId(), asset.getName(), oldAsset != null ? oldAsset.getName() : null);
Asset savedAsset;

3
dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java

@ -61,8 +61,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements
@Override
public CalculatedField save(CalculatedField calculatedField) {
CalculatedField oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId);
return doSave(calculatedField, oldCalculatedField);
return save(calculatedField, true);
}
@Override

27
dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java

@ -28,6 +28,8 @@ import org.thingsboard.server.cache.customer.CustomerCacheEvictEvent;
import org.thingsboard.server.cache.customer.CustomerCacheKey;
import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
@ -139,20 +141,29 @@ public class CustomerServiceImpl extends AbstractCachedEntityService<CustomerCac
@Override
@Transactional
public Customer saveCustomer(Customer customer) {
return saveCustomer(customer, true);
return saveCustomer(customer, true, NameConflictStrategy.DEFAULT);
}
@Override
@Transactional
public Customer saveCustomer(Customer customer, NameConflictStrategy nameConflictStrategy) {
return saveCustomer(customer, true, nameConflictStrategy);
}
private Customer saveCustomer(Customer customer, boolean doValidate) {
return saveCustomer(customer, doValidate, NameConflictStrategy.DEFAULT);
}
private Customer saveCustomer(Customer customer, boolean doValidate, NameConflictStrategy nameConflictStrategy) {
log.trace("Executing saveCustomer [{}]", customer);
Customer oldCustomer = null;
String oldCustomerTitle = null;
Customer oldCustomer = (customer.getId() != null) ? customerDao.findById(customer.getTenantId(), customer.getId().getId()) : null;
if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (oldCustomer == null || !oldCustomer.getTitle().equals(customer.getTitle()))) {
uniquifyEntityName(customer, oldCustomer, customer::setTitle, EntityType.CUSTOMER, nameConflictStrategy);
}
if (doValidate) {
oldCustomer = customerValidator.validate(customer, Customer::getTenantId);
if (oldCustomer != null) {
oldCustomerTitle = oldCustomer.getTitle();
}
customerValidator.validate(customer, Customer::getTenantId);
}
var evictEvent = new CustomerCacheEvictEvent(customer.getTenantId(), customer.getTitle(), oldCustomerTitle);
var evictEvent = new CustomerCacheEvictEvent(customer.getTenantId(), customer.getTitle(), oldCustomer != null ? oldCustomer.getTitle() : null);
try {
Customer savedCustomer = customerDao.saveAndFlush(customer.getTenantId(), customer);
if (!savedCustomer.isPublic()) {

33
dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java

@ -39,6 +39,8 @@ import org.thingsboard.server.common.data.DeviceTransportType;
import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.ProfileEntityIdInfo;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.Tenant;
@ -167,6 +169,12 @@ public class DeviceServiceImpl extends CachedVersionedEntityService<DeviceCacheK
return doSaveDevice(device, accessToken, true);
}
@Transactional
@Override
public Device saveDeviceWithAccessToken(Device device, String accessToken, NameConflictStrategy nameConflictStrategy) {
return doSaveDevice(device, accessToken, true, nameConflictStrategy);
}
@Override
public Device saveDevice(Device device, boolean doValidate) {
return doSaveDevice(device, null, doValidate);
@ -181,7 +189,13 @@ public class DeviceServiceImpl extends CachedVersionedEntityService<DeviceCacheK
@Transactional
@Override
public Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials) {
Device savedDevice = this.saveDeviceWithoutCredentials(device, true);
return this.saveDeviceWithCredentials(device, deviceCredentials, NameConflictStrategy.DEFAULT);
}
@Transactional
@Override
public Device saveDeviceWithCredentials(Device device, DeviceCredentials deviceCredentials, NameConflictStrategy nameConflictStrategy) {
Device savedDevice = this.saveDeviceWithoutCredentials(device, true, nameConflictStrategy);
deviceCredentials.setDeviceId(savedDevice.getId());
if (device.getId() == null) {
deviceCredentialsService.createDeviceCredentials(savedDevice.getTenantId(), deviceCredentials);
@ -198,7 +212,11 @@ public class DeviceServiceImpl extends CachedVersionedEntityService<DeviceCacheK
}
private Device doSaveDevice(Device device, String accessToken, boolean doValidate) {
Device savedDevice = this.saveDeviceWithoutCredentials(device, doValidate);
return doSaveDevice(device, accessToken, doValidate, NameConflictStrategy.DEFAULT);
}
private Device doSaveDevice(Device device, String accessToken, boolean doValidate, NameConflictStrategy nameConflictStrategy) {
Device savedDevice = this.saveDeviceWithoutCredentials(device, doValidate, nameConflictStrategy);
if (device.getId() == null) {
DeviceCredentials deviceCredentials = new DeviceCredentials();
deviceCredentials.setDeviceId(new DeviceId(savedDevice.getUuidId()));
@ -209,13 +227,14 @@ public class DeviceServiceImpl extends CachedVersionedEntityService<DeviceCacheK
return savedDevice;
}
private Device saveDeviceWithoutCredentials(Device device, boolean doValidate) {
private Device saveDeviceWithoutCredentials(Device device, boolean doValidate, NameConflictStrategy nameConflictStrategy) {
log.trace("Executing saveDevice [{}]", device);
Device oldDevice = null;
Device oldDevice = (device.getId() != null) ? deviceDao.findById(device.getTenantId(), device.getId().getId()) : null;
if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (oldDevice == null || !oldDevice.getName().equals(device.getName()))) {
uniquifyEntityName(device, oldDevice, device::setName, EntityType.DEVICE, nameConflictStrategy);
}
if (doValidate) {
oldDevice = deviceValidator.validate(device, Device::getTenantId);
} else if (device.getId() != null) {
oldDevice = findDeviceById(device.getTenantId(), device.getId());
deviceValidator.validate(device, Device::getTenantId);
}
DeviceCacheEvictEvent deviceCacheEvictEvent = new DeviceCacheEvictEvent(device.getTenantId(), device.getId(), device.getName(), oldDevice != null ? oldDevice.getName() : null);
try {

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

@ -22,15 +22,22 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
import org.thingsboard.common.util.DebugModeUtil;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.HasDebugSettings;
import org.thingsboard.server.common.data.HasName;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.debug.DebugSettings;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.dao.Dao;
import org.thingsboard.server.dao.alarm.AlarmService;
import org.thingsboard.server.dao.cf.CalculatedFieldService;
import org.thingsboard.server.dao.edge.EdgeService;
@ -44,7 +51,12 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.UniquifyStrategy.RANDOM;
@Slf4j
public abstract class AbstractEntityService {
@ -83,6 +95,9 @@ public abstract class AbstractEntityService {
@Lazy
protected TbTenantProfileCache tbTenantProfileCache;
@Autowired
protected EntityDaoRegistry entityDaoRegistry;
@Value("${debug.settings.default_duration:15}")
private int defaultDebugDurationMinutes;
@ -155,4 +170,33 @@ public abstract class AbstractEntityService {
private long getMaxDebugAllUntil(TenantId tenantId, long now) {
return now + TimeUnit.MINUTES.toMillis(DebugModeUtil.getMaxDebugAllDuration(tbTenantProfileCache.get(tenantId).getDefaultProfileConfiguration().getMaxDebugModeDurationMinutes(), defaultDebugDurationMinutes));
}
protected <E extends HasId<?> & HasTenantId & HasName> void uniquifyEntityName(E entity, E oldEntity, Consumer<String> setName, EntityType entityType, NameConflictStrategy strategy) {
Dao<?> dao = entityDaoRegistry.getDao(entityType);
List<EntityInfo> existingEntities = dao.findEntityInfosByNamePrefix(entity.getTenantId(), entity.getName());
Set<String> existingNames = existingEntities.stream()
.filter(e -> (oldEntity == null || !e.getId().equals(oldEntity.getId())))
.map(EntityInfo::getName)
.collect(Collectors.toSet());
if (existingNames.contains(entity.getName())) {
String uniqueName = generateUniqueName(entity.getName(), existingNames, strategy);
setName.accept(uniqueName);
}
}
private String generateUniqueName(String baseName, Set<String> existingNames, NameConflictStrategy strategy) {
String newName;
int index = 1;
String separator = strategy.separator();
boolean isRandom = strategy.uniquifyStrategy() == RANDOM;
do {
String suffix = isRandom ? StringUtils.randomAlphanumeric(6) : String.valueOf(index++);
newName = baseName + separator + suffix;
} while (existingNames.contains(newName));
return newName;
}
}

20
dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java

@ -29,6 +29,8 @@ import org.thingsboard.server.common.data.EntitySubtype;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.EntityViewInfo;
import org.thingsboard.server.common.data.NameConflictPolicy;
import org.thingsboard.server.common.data.NameConflictStrategy;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.edge.Edge;
@ -110,14 +112,24 @@ public class EntityViewServiceImpl extends CachedVersionedEntityService<EntityVi
return saveEntityView(entityView, true);
}
@Override
public EntityView saveEntityView(EntityView entityView, NameConflictStrategy nameConflictStrategy) {
return saveEntityView(entityView, true, nameConflictStrategy);
}
@Override
public EntityView saveEntityView(EntityView entityView, boolean doValidate) {
return saveEntityView(entityView, doValidate, NameConflictStrategy.DEFAULT);
}
private EntityView saveEntityView(EntityView entityView, boolean doValidate, NameConflictStrategy nameConflictStrategy) {
log.trace("Executing save entity view [{}]", entityView);
EntityView old = null;
EntityView old = (entityView.getId() != null) ? entityViewDao.findById(entityView.getTenantId(), entityView.getId().getId()) : null;
if (nameConflictStrategy.policy() == NameConflictPolicy.UNIQUIFY && (old == null || !entityView.getName().equals(old.getName()))) {
uniquifyEntityName(entityView, old, entityView::setName, EntityType.ENTITY_VIEW, nameConflictStrategy);
}
if (doValidate) {
old = entityViewValidator.validate(entityView, EntityView::getTenantId);
} else if (entityView.getId() != null) {
old = findEntityViewById(entityView.getTenantId(), entityView.getId(), false);
entityViewValidator.validate(entityView, EntityView::getTenantId);
}
try {
EntityView saved = entityViewDao.save(entityView.getTenantId(), entityView);

22
dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java

@ -50,12 +50,14 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel;
import org.thingsboard.server.common.data.relation.RelationTypeGroup;
import org.thingsboard.server.common.data.relation.RelationsSearchParameters;
import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.dao.entity.EntityService;
import org.thingsboard.server.dao.eventsourcing.RelationActionEvent;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.ConstraintValidator;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import org.thingsboard.server.dao.sql.relation.JpaRelationQueryExecutorService;
import org.thingsboard.server.dao.usagerecord.ApiLimitService;
import java.util.ArrayList;
import java.util.Collections;
@ -71,6 +73,7 @@ import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.service.Validator.validatePositiveNumber;
/**
* Created by ashvayka on 28.04.17.
@ -85,6 +88,8 @@ public class BaseRelationService implements RelationService {
private final ApplicationEventPublisher eventPublisher;
private final JpaExecutorService executor;
private final JpaRelationQueryExecutorService relationsExecutor;
private final ApiLimitService apiLimitService;
protected ScheduledExecutorService timeoutExecutorService;
@Value("${sql.relations.query_timeout:20}")
@ -93,13 +98,14 @@ public class BaseRelationService implements RelationService {
public BaseRelationService(RelationDao relationDao, @Lazy EntityService entityService,
TbTransactionalCache<RelationCacheKey, RelationCacheValue> cache,
ApplicationEventPublisher eventPublisher, JpaExecutorService executor,
JpaRelationQueryExecutorService relationsExecutor) {
JpaRelationQueryExecutorService relationsExecutor, ApiLimitService apiLimitService) {
this.relationDao = relationDao;
this.entityService = entityService;
this.cache = cache;
this.eventPublisher = eventPublisher;
this.executor = executor;
this.relationsExecutor = relationsExecutor;
this.apiLimitService = apiLimitService;
}
@PostConstruct
@ -504,14 +510,18 @@ public class BaseRelationService implements RelationService {
log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery);
validateId(tenantId, id -> "Invalid tenant id: " + id);
validate(relationPathQuery);
int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument);
validatePositiveNumber(limit, "Max related entities limit for relation path query must be positive!");
if (relationPathQuery.levels().size() == 1) {
RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0);
return switch (relationPathLevel.direction()) {
var relationsFuture = switch (relationPathLevel.direction()) {
case FROM -> findByFromAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
case TO -> findByToAndTypeAsync(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
};
return Futures.transform(relationsFuture, entityRelations -> entityRelations.size() > limit ?
entityRelations.subList(0, limit) : entityRelations, MoreExecutors.directExecutor());
}
return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery));
return executor.submit(() -> relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit));
}
@Override
@ -519,14 +529,16 @@ public class BaseRelationService implements RelationService {
log.trace("Executing findByRelationPathQuery, tenantId [{}], relationPathQuery {}", tenantId, relationPathQuery);
validateId(tenantId, id -> "Invalid tenant id: " + id);
validate(relationPathQuery);
int limit = (int) apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxRelatedEntitiesToReturnPerCfArgument);
if (relationPathQuery.levels().size() == 1) {
RelationPathLevel relationPathLevel = relationPathQuery.levels().get(0);
return switch (relationPathLevel.direction()) {
var relations = switch (relationPathLevel.direction()) {
case FROM -> findByFromAndType(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
case TO -> findByToAndType(tenantId, relationPathQuery.rootEntityId(), relationPathLevel.relationType(), RelationTypeGroup.COMMON);
};
return relations.size() > limit ? relations.subList(0, limit) : relations;
}
return relationDao.findByRelationPathQuery(tenantId, relationPathQuery);
return relationDao.findByRelationPathQuery(tenantId, relationPathQuery, limit);
}
private void validate(EntityRelationPathQuery relationPathQuery) {

2
dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java

@ -72,6 +72,6 @@ public interface RelationDao {
List<EntityRelation> findRuleNodeToRuleChainRelations(RuleChainType ruleChainType, int limit);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery);
List<EntityRelation> findByRelationPathQuery(TenantId tenantId, EntityRelationPathQuery relationPathQuery, int limit);
}

5
dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java

@ -21,6 +21,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.thingsboard.server.common.data.EntityInfo;
import org.thingsboard.server.common.data.edqs.fields.AssetFields;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.dao.ExportableEntityRepository;
@ -103,6 +104,10 @@ public interface AssetRepository extends JpaRepository<AssetEntity, UUID>, Expor
AssetEntity findByTenantIdAndName(UUID tenantId, String name);
@Query("SELECT new org.thingsboard.server.common.data.EntityInfo(a.id, 'ASSET', a.name) " +
"FROM AssetEntity a WHERE a.tenantId = :tenantId AND a.name LIKE CONCAT(:prefix, '%')")
List<EntityInfo> findEntityInfosByNamePrefix(UUID tenantId, String prefix);
@Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " +
"AND a.type = :type " +
"AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " +

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

Loading…
Cancel
Save