diff --git a/application/pom.xml b/application/pom.xml index d775942f8a..33bc0972d4 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard application @@ -186,8 +186,8 @@ jjwt - org.freemarker - freemarker + org.springframework.boot + spring-boot-starter-freemarker commons-io @@ -381,6 +381,44 @@ org.rocksdb rocksdbjni + + dev.langchain4j + langchain4j-open-ai + + + dev.langchain4j + langchain4j-azure-open-ai + + + dev.langchain4j + langchain4j-google-ai-gemini + + + dev.langchain4j + langchain4j-vertex-ai-gemini + + + dev.langchain4j + langchain4j-mistral-ai + + + dev.langchain4j + langchain4j-anthropic + + + dev.langchain4j + langchain4j-bedrock + + + dev.langchain4j + langchain4j-github-models + + + com.azure + azure-core-test + + + diff --git a/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md b/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md index a0536f113a..f12eaa5fc0 100644 --- a/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md +++ b/application/src/main/data/json/edge/instructions/upgrade/docker/upgrade_preparing.md @@ -32,19 +32,7 @@ docker run --rm -v tb-edge-postgres-data:/volume -v ~/.mytb-edge-data/db:/backup After completing the data migration to the newly created Docker volumes, you'll need to update the volume mounts in your Docker Compose configuration. Modify the `docker-compose.yml` file for ThingsBoard Edge to update the volume settings. -First, please update docker compose file version. Find next snippet: -```text -version: '3.0' -... -``` - -And replace it with: -```text -version: '3.8' -... -``` - -Then update volume mounts. Locate the following snippet: +Update volume mounts. Locate the following snippet: ```text volumes: - ~/.mytb-edge-data:/data diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index e4bbe4c69e..add832ea6e 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,48 +14,33 @@ -- limitations under the License. -- --- UPDATE TENANT PROFILE CASSANDRA RATE LIMITS START - -UPDATE tenant_profile -SET profile_data = jsonb_set( - profile_data, - '{configuration}', - ( - (profile_data -> 'configuration') - 'cassandraQueryTenantRateLimitsConfiguration' - || - COALESCE( - CASE - WHEN profile_data -> 'configuration' -> - 'cassandraQueryTenantRateLimitsConfiguration' IS NOT NULL THEN - jsonb_build_object( - 'cassandraReadQueryTenantCoreRateLimits', - profile_data -> 'configuration' -> 'cassandraQueryTenantRateLimitsConfiguration', - 'cassandraWriteQueryTenantCoreRateLimits', - profile_data -> 'configuration' -> 'cassandraQueryTenantRateLimitsConfiguration', - 'cassandraReadQueryTenantRuleEngineRateLimits', - profile_data -> 'configuration' -> 'cassandraQueryTenantRateLimitsConfiguration', - 'cassandraWriteQueryTenantRuleEngineRateLimits', - profile_data -> 'configuration' -> 'cassandraQueryTenantRateLimitsConfiguration' - ) - END, - '{}'::jsonb - ) - ) - ) -WHERE profile_data -> 'configuration' ? 'cassandraQueryTenantRateLimitsConfiguration'; - --- UPDATE TENANT PROFILE CASSANDRA RATE LIMITS END - --- UPDATE NOTIFICATION RULE CASSANDRA RATE LIMITS START - -UPDATE notification_rule -SET trigger_config = REGEXP_REPLACE( - trigger_config, - '"CASSANDRA_QUERIES"', - '"CASSANDRA_WRITE_QUERIES_CORE","CASSANDRA_READ_QUERIES_CORE","CASSANDRA_WRITE_QUERIES_RULE_ENGINE","CASSANDRA_READ_QUERIES_RULE_ENGINE","CASSANDRA_WRITE_QUERIES_MONOLITH","CASSANDRA_READ_QUERIES_MONOLITH"', - 'g' - ) -WHERE trigger_type = 'RATE_LIMITS' - AND trigger_config LIKE '%"CASSANDRA_QUERIES"%'; - --- UPDATE NOTIFICATION RULE CASSANDRA RATE LIMITS END +-- UPDATE OTA PACKAGE EXTERNAL ID START + +ALTER TABLE ota_package + ADD COLUMN IF NOT EXISTS external_id uuid; + +DO +$$ + BEGIN + IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'ota_package_external_id_unq_key') THEN + ALTER TABLE ota_package ADD CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id); + END IF; + END; +$$; + +-- UPDATE OTA PACKAGE EXTERNAL ID END + +-- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT START + +DROP INDEX IF EXISTS idx_device_external_id; +DROP INDEX IF EXISTS idx_device_profile_external_id; +DROP INDEX IF EXISTS idx_asset_external_id; +DROP INDEX IF EXISTS idx_entity_view_external_id; +DROP INDEX IF EXISTS idx_rule_chain_external_id; +DROP INDEX IF EXISTS idx_dashboard_external_id; +DROP INDEX IF EXISTS idx_customer_external_id; +DROP INDEX IF EXISTS idx_widgets_bundle_external_id; + +-- DROP INDEXES THAT DUPLICATE UNIQUE CONSTRAINT END + +ALTER TABLE mobile_app ADD COLUMN IF NOT EXISTS title varchar(255); \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 0125c7c07d..ea46ce86eb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -35,6 +35,7 @@ import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; @@ -62,6 +63,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.common.stats.TbApiUsageReportClient; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -311,6 +313,14 @@ public class ActorSystemContext { @Getter private AuditLogService auditLogService; + @Autowired + @Getter + private RuleEngineAiChatModelService aiChatModelService; + + @Autowired + @Getter + private AiModelService aiModelService; + @Autowired @Getter private EntityViewService entityViewService; diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index ebc4e60709..35539834c3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -68,7 +68,7 @@ import java.util.stream.Collectors; */ @Slf4j public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareMsgProcessor { - // (1 for result persistence + 1 for the state persistence ) + // (1 for result persistence + 1 for the state persistence) public static final int CALLBACKS_PER_CF = 2; final TenantId tenantId; diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index dff0cd4cf1..6374e4016d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -28,6 +28,7 @@ import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService; import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; @@ -76,6 +77,7 @@ import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -1024,6 +1026,16 @@ public class DefaultTbContext implements TbContext { return mainCtx.getAuditLogService(); } + @Override + public RuleEngineAiChatModelService getAiChatModelService() { + return mainCtx.getAiChatModelService(); + } + + @Override + public AiModelService getAiModelService() { + return mainCtx.getAiModelService(); + } + @Override public MqttClientSettings getMqttClientSettings() { return mainCtx.getMqttClientSettings(); diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index 93cf9da8e0..2fbc89a84d 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -26,9 +26,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.DefaultAuthenticationEventPublisher; -import org.springframework.security.config.annotation.ObjectPostProcessor; -import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -183,15 +181,12 @@ public class ThingsboardSecurityConfiguration { } @Bean - public AuthenticationManager authenticationManager(ObjectPostProcessor objectPostProcessor) throws Exception { - DefaultAuthenticationEventPublisher eventPublisher = objectPostProcessor - .postProcess(new DefaultAuthenticationEventPublisher()); - var auth = new AuthenticationManagerBuilder(objectPostProcessor); - auth.authenticationEventPublisher(eventPublisher); - auth.authenticationProvider(restAuthenticationProvider); - auth.authenticationProvider(jwtAuthenticationProvider); - auth.authenticationProvider(refreshTokenAuthenticationProvider); - return auth.build(); + public AuthenticationManager authenticationManager() { + return new ProviderManager(List.of( + restAuthenticationProvider, + jwtAuthenticationProvider, + refreshTokenAuthenticationProvider + )); } @Autowired @@ -265,4 +260,5 @@ public class ThingsboardSecurityConfiguration { return new CorsFilter(source); } } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 6b3132a1d2..371ca4d3e4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -37,14 +37,13 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.context.request.async.DeferredResult; @@ -125,14 +124,13 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the Administration Settings object using key (getAdminSettings)", notes = "Get the Administration Settings object using specified string key. Referencing non-existing key will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/{key}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/settings/{key}") public AdminSettings getAdminSettings( @Parameter(description = "A string value of the key (e.g. 'general' or 'mail').") @PathVariable("key") String key) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); AdminSettings adminSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key), "No Administration settings found for key: " + key); - if (adminSettings.getKey().equals("mail")) { + if (adminSettings.getKey().equals(MAIL_SETTINGS_KEY)) { ((ObjectNode) adminSettings.getJsonValue()).remove("password"); ((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken"); } @@ -144,15 +142,14 @@ public class AdminController extends BaseController { "The Administration Settings Id will be present in the response. Specify the Administration Settings Id when you would like to update the Administration Settings. " + "Referencing non-existing Administration Settings Id will cause an error." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/settings") public AdminSettings saveAdminSettings( @Parameter(description = "A JSON value representing the Administration Settings.") @RequestBody AdminSettings adminSettings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.WRITE); adminSettings.setTenantId(getTenantId()); adminSettings = checkNotNull(adminSettingsService.saveAdminSettings(TenantId.SYS_TENANT_ID, adminSettings)); - if (adminSettings.getKey().equals("mail")) { + if (adminSettings.getKey().equals(MAIL_SETTINGS_KEY)) { mailService.updateMailConfiguration(); ((ObjectNode) adminSettings.getJsonValue()).remove("password"); ((ObjectNode) adminSettings.getJsonValue()).remove("refreshToken"); @@ -165,8 +162,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the Security Settings object (getSecuritySettings)", notes = "Get the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/securitySettings", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/securitySettings") public SecuritySettings getSecuritySettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return checkNotNull(securitySettingsService.getSecuritySettings()); @@ -175,8 +171,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Update Security Settings (saveSecuritySettings)", notes = "Updates the Security Settings object that contains password policy, etc." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/securitySettings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/securitySettings") public SecuritySettings saveSecuritySettings( @Parameter(description = "A JSON value representing the Security Settings.") @RequestBody SecuritySettings securitySettings) throws ThingsboardException { @@ -188,8 +183,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Get the JWT Settings object (getJwtSettings)", notes = "Get the JWT Settings object that contains JWT token policy, etc. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/jwtSettings", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/jwtSettings") public JwtSettings getJwtSettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return checkNotNull(jwtSettingsService.getJwtSettings()); @@ -198,8 +192,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Update JWT Settings (saveJwtSettings)", notes = "Updates the JWT Settings object that contains JWT token policy, etc. The tokenSigningKey field is a Base64 encoded string." + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/jwtSettings", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/jwtSettings") public JwtPair saveJwtSettings( @Parameter(description = "A JSON value representing the JWT Settings.") @RequestBody JwtSettings jwtSettings) throws ThingsboardException { @@ -213,15 +206,15 @@ public class AdminController extends BaseController { notes = "Attempts to send test email to the System Administrator User using Mail Settings provided as a parameter. " + "You may change the 'To' email in the user profile of the System Administrator. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/testMail", method = RequestMethod.POST) + @PostMapping(value = "/settings/testMail") public void sendTestMail( @Parameter(description = "A JSON value representing the Mail Settings.") @RequestBody AdminSettings adminSettings) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); adminSettings = checkNotNull(adminSettings); - if (adminSettings.getKey().equals("mail")) { + if (adminSettings.getKey().equals(MAIL_SETTINGS_KEY)) { if (adminSettings.getJsonValue().has("enableOauth2") && adminSettings.getJsonValue().get("enableOauth2").asBoolean()) { - AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); + AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY)); JsonNode refreshToken = mailSettings.getJsonValue().get("refreshToken"); if (refreshToken == null) { throw new ThingsboardException("Refresh token was not generated. Please, generate refresh token.", ThingsboardErrorCode.GENERAL); @@ -230,7 +223,7 @@ public class AdminController extends BaseController { settings.put("refreshToken", refreshToken.asText()); } else { if (!adminSettings.getJsonValue().has("password")) { - AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail")); + AdminSettings mailSettings = checkNotNull(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, MAIL_SETTINGS_KEY)); ((ObjectNode) adminSettings.getJsonValue()).put("password", mailSettings.getJsonValue().get("password").asText()); } } @@ -251,7 +244,7 @@ public class AdminController extends BaseController { notes = "Attempts to send test sms to the System Administrator User using SMS Settings and phone number provided as a parameters of the request. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/settings/testSms", method = RequestMethod.POST) + @PostMapping(value = "/settings/testSms") public void sendTestSms( @Parameter(description = "A JSON value representing the Test SMS request.") @RequestBody TestSmsRequest testSmsRequest) throws ThingsboardException { @@ -325,7 +318,7 @@ public class AdminController extends BaseController { notes = "Deletes the repository settings." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/repositorySettings", method = RequestMethod.DELETE) + @DeleteMapping(value = "/repositorySettings") @ResponseStatus(value = HttpStatus.OK) public DeferredResult deleteRepositorySettings() throws Exception { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); @@ -335,7 +328,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Check repository access (checkRepositoryAccess)", notes = "Attempts to check repository access. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/repositorySettings/checkAccess", method = RequestMethod.POST) + @PostMapping(value = "/repositorySettings/checkAccess") public DeferredResult checkRepositoryAccess( @Parameter(description = "A JSON value representing the Repository Settings.") @RequestBody RepositorySettings settings) throws Exception { @@ -376,7 +369,7 @@ public class AdminController extends BaseController { notes = "Deletes the auto commit settings." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/autoCommitSettings", method = RequestMethod.DELETE) + @DeleteMapping(value = "/autoCommitSettings") @ResponseStatus(value = HttpStatus.OK) public void deleteAutoCommitSettings() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.VERSION_CONTROL, Operation.DELETE); @@ -387,9 +380,8 @@ public class AdminController extends BaseController { notes = "Check notifications about new platform releases. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/updates", method = RequestMethod.GET) - @ResponseBody - public UpdateMessage checkUpdates() throws ThingsboardException { + @GetMapping(value = "/updates") + public UpdateMessage checkUpdates() { return updateService.checkUpdates(); } @@ -397,9 +389,8 @@ public class AdminController extends BaseController { notes = "Get main information about system. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/systemInfo", method = RequestMethod.GET) - @ResponseBody - public SystemInfo getSystemInfo() throws ThingsboardException { + @GetMapping(value = "/systemInfo") + public SystemInfo getSystemInfo() { return systemInfoService.getSystemInfo(); } @@ -407,8 +398,7 @@ public class AdminController extends BaseController { notes = "Get information about enabled/disabled features. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/featuresInfo", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/featuresInfo") public FeaturesInfo getFeaturesInfo() { return systemInfoService.getFeaturesInfo(); } @@ -417,8 +407,7 @@ public class AdminController extends BaseController { "double quotes. After successful authentication with OAuth2 provider and user consent for requested scope, it makes a redirect to this path so that the platform can do " + "further log in processing and generating access tokens. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mail/oauth2/loginProcessingUrl", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/mail/oauth2/loginProcessingUrl") public String getMailProcessingUrl() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return "\"/api/admin/mail/oauth2/code\""; @@ -427,7 +416,7 @@ public class AdminController extends BaseController { @ApiOperation(value = "Redirect user to mail provider login page. ", notes = "After user logged in and provided access" + "provider sends authorization code to specified redirect uri.)") @PreAuthorize("hasAuthority('SYS_ADMIN')") - @RequestMapping(value = "/mail/oauth2/authorize", method = RequestMethod.GET, produces = "application/text") + @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { @@ -452,7 +441,7 @@ public class AdminController extends BaseController { .build() + "\""; } - @RequestMapping(value = "/mail/oauth2/code", params = {"code", "state"}, method = RequestMethod.GET) + @GetMapping(value = "/mail/oauth2/code", params = {"code", "state"}) public void codeProcessingUrl( @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { diff --git a/application/src/main/java/org/thingsboard/server/controller/AiModelController.java b/application/src/main/java/org/thingsboard/server/controller/AiModelController.java new file mode 100644 index 0000000000..34a0bbebfc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AiModelController.java @@ -0,0 +1,178 @@ +/** + * 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.controller; + +import com.google.common.util.concurrent.ListenableFuture; +import dev.langchain4j.model.chat.request.ChatRequest; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.dto.TbChatRequest; +import org.thingsboard.server.common.data.ai.dto.TbChatResponse; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ai.AiChatModelService; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; + +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.server.controller.ControllerConstants.AI_MODEL_TEXT_SEARCH_DESCRIPTION; +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; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; + +@Validated +@RestController +@TbCoreComponent +@RequiredArgsConstructor +@RequestMapping("/api/ai/model") +class AiModelController extends BaseController { + + private final AiChatModelService aiChatModelService; + + @ApiOperation( + value = "Create or update AI model (saveAiModel)", + notes = "Creates or updates an AI model record.\n\n" + + "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new record and returns it in the `id` field of the response.\n\n" + + "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + + "Tenant ID for the AI model will be taken from the authenticated user making the request, regardless of any value provided in the request body." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping + public AiModel saveAiModel(@RequestBody @Valid AiModel model) throws ThingsboardException { + var user = getCurrentUser(); + model.setTenantId(user.getTenantId()); + checkEntity(model.getId(), model, Resource.AI_MODEL); + return tbAiModelService.save(model, user); + } + + @ApiOperation( + value = "Get AI model by ID (getAiModelById)", + notes = "Fetches an AI model record by its `id`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/{modelUuid}") + public AiModel getAiModelById( + @Parameter( + description = "ID of the AI model record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable UUID modelUuid + ) throws ThingsboardException { + return checkAiModelId(new AiModelId(modelUuid), Operation.READ); + } + + @ApiOperation( + value = "Get AI models (getAiModels)", + notes = "Returns a page of AI models. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping + public PageData getAiModels( + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = AI_MODEL_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "modelId"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + var user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.READ); + var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return aiModelService.findAiModelsByTenantId(user.getTenantId(), pageLink); + } + + @ApiOperation( + value = "Delete AI model by ID (deleteAiModelById)", + notes = "Deletes the AI model record by its `id`. " + + "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + + "If no such record exists, the endpoint returns `false`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/{modelUuid}") + public boolean deleteAiModelById( + @Parameter( + description = "ID of the AI model record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable UUID modelUuid + ) throws ThingsboardException { + var user = getCurrentUser(); + var modelId = new AiModelId(modelUuid); + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE); + Optional toDelete = aiModelService.findAiModelByTenantIdAndId(user.getTenantId(), modelId); + if (toDelete.isEmpty()) { + return false; + } + accessControlService.checkPermission(user, Resource.AI_MODEL, Operation.DELETE, modelId, toDelete.get()); + return tbAiModelService.delete(toDelete.get(), user); + } + + @ApiOperation( + value = "Send request to AI chat model (sendChatRequest)", + notes = "Submits a single prompt - made up of an optional system message and a required user message - to the specified AI chat model " + + "and returns either the generated answer or an error envelope." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/chat") + public DeferredResult sendChatRequest(@Valid @RequestBody TbChatRequest tbChatRequest) { + ChatRequest langChainChatRequest = tbChatRequest.toLangChainChatRequest(); + AiChatModelConfig chatModelConfig = tbChatRequest.chatModelConfig(); + + ListenableFuture future = aiChatModelService.sendChatRequestAsync(chatModelConfig, langChainChatRequest) + .transform(chatResponse -> (TbChatResponse) new TbChatResponse.Success(chatResponse.aiMessage().text()), directExecutor()) + .catching(Throwable.class, ex -> new TbChatResponse.Failure(ex.getMessage()), directExecutor()); + + Integer requestTimeoutSeconds = chatModelConfig.timeoutSeconds(); + return requestTimeoutSeconds != null ? wrapFuture(future, Duration.ofSeconds(requestTimeoutSeconds).toMillis()) : wrapFuture(future); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index 9d123319ef..b12a253b36 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -19,12 +19,13 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; @@ -54,6 +55,7 @@ import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LI @RequiredArgsConstructor @RequestMapping("/api") public class AlarmCommentController extends BaseController { + public static final String ALARM_ID = "alarmId"; public static final String ALARM_COMMENT_ID = "commentId"; @@ -68,8 +70,7 @@ public class AlarmCommentController extends BaseController { "\n\n If comment type is not specified the default value 'OTHER' will be saved. If 'alarmId' or 'userId' specified in body it will be ignored." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/comment", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/alarm/{alarmId}/comment") public AlarmComment saveAlarmComment(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the comment.") @RequestBody AlarmComment alarmComment) throws ThingsboardException { checkParameter(ALARM_ID, strAlarmId); @@ -82,8 +83,7 @@ public class AlarmCommentController extends BaseController { @ApiOperation(value = "Delete Alarm comment (deleteAlarmComment)", notes = "Deletes the Alarm comment. Referencing non-existing Alarm comment Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/comment/{commentId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/alarm/{alarmId}/comment/{commentId}") public void deleteAlarmComment(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId, @Parameter(description = ALARM_COMMENT_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_COMMENT_ID) String strCommentId) throws ThingsboardException { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); @@ -98,8 +98,7 @@ public class AlarmCommentController extends BaseController { notes = "Returns a page of alarm comments for specified alarm. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/comment", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarm/{alarmId}/comment") public PageData getAlarmComments( @Parameter(description = ALARM_ID_PARAM_DESCRIPTION, required = true) @PathVariable(ALARM_ID) String strAlarmId, @@ -118,4 +117,5 @@ public class AlarmCommentController extends BaseController { PageLink pageLink = createPageLink(pageSize, page, null, sortProperty, sortOrder); return checkNotNull(alarmCommentService.findAlarmComments(alarm.getTenantId(), alarmId, pageLink)); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index dd8d883145..f5a04e1e48 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -21,12 +21,13 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.EntitySubtype; @@ -58,7 +59,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.UUID; -import java.util.concurrent.ExecutionException; import static org.thingsboard.server.controller.ControllerConstants.ALARM_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ALARM_INFO_DESCRIPTION; @@ -104,8 +104,7 @@ public class AlarmController extends BaseController { @ApiOperation(value = "Get Alarm (getAlarmById)", notes = "Fetch the Alarm object based on the provided Alarm Id. " + ALARM_SECURITY_CHECK) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarm/{alarmId}") public Alarm getAlarmById(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws ThingsboardException { checkParameter(ALARM_ID, strAlarmId); @@ -117,8 +116,7 @@ public class AlarmController extends BaseController { notes = "Fetch the Alarm Info object based on the provided Alarm Id. " + ALARM_SECURITY_CHECK + ALARM_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/info/{alarmId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarm/info/{alarmId}") public AlarmInfo getAlarmInfoById(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws ThingsboardException { checkParameter(ALARM_ID, strAlarmId); @@ -136,11 +134,9 @@ public class AlarmController extends BaseController { "If the user tries to create 'HighTemperature' alarm for the same device again, the previous alarm will be updated (the 'end_ts' will be set to current timestamp). " + "If the user clears the alarm (see 'Clear Alarm(clearAlarm)'), than new alarm with the same type and same device may be created. " + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Alarm entity. " + - TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH - ) + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/alarm") public Alarm saveAlarm(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the alarm.") @RequestBody Alarm alarm) throws ThingsboardException { alarm.setTenantId(getTenantId()); checkNotNull(alarm.getOriginator()); @@ -155,8 +151,7 @@ public class AlarmController extends BaseController { @ApiOperation(value = "Delete Alarm (deleteAlarm)", notes = "Deletes the Alarm. Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/alarm/{alarmId}") public boolean deleteAlarm(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws ThingsboardException { checkParameter(ALARM_ID, strAlarmId); AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); @@ -169,7 +164,7 @@ public class AlarmController extends BaseController { "Once acknowledged, the 'ack_ts' field will be set to current timestamp and special rule chain event 'ALARM_ACK' will be generated. " + "Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/ack", method = RequestMethod.POST) + @PostMapping(value = "/alarm/{alarmId}/ack") @ResponseStatus(value = HttpStatus.OK) public AlarmInfo ackAlarm(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws Exception { checkParameter(ALARM_ID, strAlarmId); @@ -184,7 +179,7 @@ public class AlarmController extends BaseController { "Once cleared, the 'clear_ts' field will be set to current timestamp and special rule chain event 'ALARM_CLEAR' will be generated. " + "Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/clear", method = RequestMethod.POST) + @PostMapping(value = "/alarm/{alarmId}/clear") @ResponseStatus(value = HttpStatus.OK) public AlarmInfo clearAlarm(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId) throws Exception { checkParameter(ALARM_ID, strAlarmId); @@ -200,7 +195,7 @@ public class AlarmController extends BaseController { "(or ALARM_REASSIGNED in case of assigning already assigned alarm) will be generated. " + "Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/assign/{assigneeId}", method = RequestMethod.POST) + @PostMapping(value = "/alarm/{alarmId}/assign/{assigneeId}") @ResponseStatus(value = HttpStatus.OK) public Alarm assignAlarm(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId, @@ -221,7 +216,7 @@ public class AlarmController extends BaseController { "Once unassigned, the 'assign_ts' field will be set to current timestamp and special rule chain event 'ALARM_UNASSIGNED' will be generated. " + "Referencing non-existing Alarm Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{alarmId}/assign", method = RequestMethod.DELETE) + @DeleteMapping(value = "/alarm/{alarmId}/assign") @ResponseStatus(value = HttpStatus.OK) public Alarm unassignAlarm(@Parameter(description = ALARM_ID_PARAM_DESCRIPTION) @PathVariable(ALARM_ID) String strAlarmId @@ -236,8 +231,7 @@ public class AlarmController extends BaseController { notes = "Returns a page of alarms for the selected entity. Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/{entityType}/{entityId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarm/{entityType}/{entityId}") public PageData getAlarms( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable(ENTITY_TYPE) String strEntityType, @@ -265,7 +259,7 @@ public class AlarmController extends BaseController { @RequestParam(required = false) Long endTime, @Parameter(description = ALARM_QUERY_FETCH_ORIGINATOR_DESCRIPTION) @RequestParam(required = false) Boolean fetchOriginator - ) throws ThingsboardException, ExecutionException, InterruptedException { + ) throws ThingsboardException { checkParameter("EntityId", strEntityId); checkParameter("EntityType", strEntityType); EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); @@ -292,8 +286,7 @@ public class AlarmController extends BaseController { "Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarms", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarms") public PageData getAllAlarms( @Parameter(description = ALARM_QUERY_SEARCH_STATUS_DESCRIPTION, schema = @Schema(allowableValues = {"ANY", "ACTIVE", "CLEARED", "ACK", "UNACK"})) @RequestParam(required = false) String searchStatus, @@ -317,7 +310,7 @@ public class AlarmController extends BaseController { @RequestParam(required = false) Long endTime, @Parameter(description = ALARM_QUERY_FETCH_ORIGINATOR_DESCRIPTION) @RequestParam(required = false) Boolean fetchOriginator - ) throws ThingsboardException, ExecutionException, InterruptedException { + ) throws ThingsboardException { AlarmSearchStatus alarmSearchStatus = StringUtils.isEmpty(searchStatus) ? null : AlarmSearchStatus.valueOf(searchStatus); AlarmStatus alarmStatus = StringUtils.isEmpty(status) ? null : AlarmStatus.valueOf(status); if (alarmSearchStatus != null && alarmStatus != null) { @@ -341,8 +334,7 @@ public class AlarmController extends BaseController { notes = "Returns a page of alarms for the selected entity. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/v2/alarm/{entityType}/{entityId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/v2/alarm/{entityType}/{entityId}") public PageData getAlarmsV2( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable(ENTITY_TYPE) String strEntityType, @@ -370,7 +362,7 @@ public class AlarmController extends BaseController { @RequestParam(required = false) Long startTime, @Parameter(description = ALARM_QUERY_END_TIME_DESCRIPTION) @RequestParam(required = false) Long endTime - ) throws ThingsboardException, ExecutionException, InterruptedException { + ) throws ThingsboardException { checkParameter("EntityId", strEntityId); checkParameter("EntityType", strEntityType); EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); @@ -407,8 +399,7 @@ public class AlarmController extends BaseController { "If the user has the authority of 'Customer User', the server returns alarms that belongs to the customer of current user. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/v2/alarms", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/v2/alarms") public PageData getAllAlarmsV2( @Parameter(description = ALARM_QUERY_SEARCH_STATUS_ARRAY_DESCRIPTION, array = @ArraySchema(schema = @Schema(type = "string", allowableValues = {"ANY", "ACTIVE", "CLEARED", "ACK", "UNACK"}))) @RequestParam(required = false) String[] statusList, @@ -432,7 +423,7 @@ public class AlarmController extends BaseController { @RequestParam(required = false) Long startTime, @Parameter(description = ALARM_QUERY_END_TIME_DESCRIPTION) @RequestParam(required = false) Long endTime - ) throws ThingsboardException, ExecutionException, InterruptedException { + ) throws ThingsboardException { List alarmStatusList = new ArrayList<>(); if (statusList != null) { for (String strStatus : statusList) { @@ -465,11 +456,9 @@ public class AlarmController extends BaseController { @ApiOperation(value = "Get Highest Alarm Severity (getHighestAlarmSeverity)", notes = "Search the alarms by originator ('entityType' and entityId') and optional 'status' or 'searchStatus' filters and returns the highest AlarmSeverity(CRITICAL, MAJOR, MINOR, WARNING or INDETERMINATE). " + - "Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH - ) + "Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/highestSeverity/{entityType}/{entityId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarm/highestSeverity/{entityType}/{entityId}") public AlarmSeverity getHighestAlarmSeverity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable(ENTITY_TYPE) String strEntityType, @@ -499,8 +488,7 @@ public class AlarmController extends BaseController { @ApiOperation(value = "Get Alarm Types (getAlarmTypes)", notes = "Returns a set of unique alarm types based on alarms that are either owned by the tenant or assigned to the customer which user is performing the request.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/alarm/types", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/alarm/types") public PageData getAlarmTypes(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @@ -508,7 +496,7 @@ public class AlarmController extends BaseController { @Parameter(description = ALARM_QUERY_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) - @RequestParam(required = false) String sortOrder) throws ThingsboardException, ExecutionException, InterruptedException { + @RequestParam(required = false) String sortOrder) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, "type", sortOrder); return checkNotNull(alarmService.findAlarmTypesByTenantId(getTenantId(), pageLink)); } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 8daee6800b..26f116b083 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -37,6 +37,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.context.request.async.AsyncRequestTimeoutException; import org.springframework.web.context.request.async.DeferredResult; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.cluster.TbClusterService; @@ -61,6 +62,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; 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.alarm.AlarmInfo; @@ -75,6 +77,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.exception.EntityVersionMismatchException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -129,6 +132,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -175,6 +179,7 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.action.EntityActionService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.entitiy.ai.TbAiModelService; import org.thingsboard.server.service.entitiy.user.TbUserSettingsService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -378,6 +383,12 @@ public abstract class BaseController { @Autowired protected CalculatedFieldService calculatedFieldService; + @Autowired + protected AiModelService aiModelService; + + @Autowired + protected TbAiModelService tbAiModelService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -390,7 +401,7 @@ public abstract class BaseController { public void handleControllerException(Exception e, HttpServletResponse response) { ThingsboardException thingsboardException = handleException(e); if (thingsboardException.getErrorCode() == ThingsboardErrorCode.GENERAL && thingsboardException.getCause() instanceof Exception - && StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) { + && StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) { e = (Exception) thingsboardException.getCause(); } else { e = thingsboardException; @@ -438,7 +449,7 @@ public abstract class BaseController { if (exception instanceof ThingsboardException) { return (ThingsboardException) exception; } else if (exception instanceof IllegalArgumentException || exception instanceof IncorrectParameterException - || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { + || exception instanceof DataValidationException || cause instanceof IncorrectParameterException) { return new ThingsboardException(exception.getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); } else if (exception instanceof MessagingException) { return new ThingsboardException("Unable to send mail", ThingsboardErrorCode.GENERAL); @@ -448,6 +459,8 @@ public abstract class BaseController { return new ThingsboardException(exception, ThingsboardErrorCode.DATABASE); } else if (exception instanceof EntityVersionMismatchException) { return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.VERSION_CONFLICT); + } else if (exception instanceof MethodArgumentTypeMismatchException) { + return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.BAD_REQUEST_PARAMS); } return new ThingsboardException(exception.getMessage(), exception, ThingsboardErrorCode.GENERAL); } @@ -634,6 +647,7 @@ public abstract class BaseController { case MOBILE_APP -> checkMobileAppId(new MobileAppId(entityId.getId()), operation); case MOBILE_APP_BUNDLE -> checkMobileAppBundleId(new MobileAppBundleId(entityId.getId()), operation); case CALCULATED_FIELD -> checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); + case AI_MODEL -> checkAiModelId(new AiModelId(entityId.getId()), operation); default -> (HasId) checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); }; } catch (Exception e) { @@ -837,6 +851,10 @@ public abstract class BaseController { return checkEntityId(jobId, jobService::findJobById, operation); } + AiModel checkAiModelId(AiModelId settingsId, Operation operation) throws ThingsboardException { + return checkEntityId(settingsId, (tenantId, id) -> aiModelService.findAiModelByTenantIdAndId(tenantId, id).orElse(null), operation); + } + protected I emptyId(EntityType entityType) { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 2dcb32cf39..5945355ef8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -17,19 +17,21 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; @@ -41,7 +43,6 @@ import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; -import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.event.EventType; @@ -49,17 +50,14 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; -import org.thingsboard.server.common.data.id.HasId; 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.config.annotations.ApiOperation; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldScriptEngine; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; -import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; import java.util.ArrayList; @@ -100,30 +98,30 @@ public class CalculatedFieldController extends BaseController { private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n" - + MARKDOWN_CODE_BLOCK_START - + "{\n" + - " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + - " \"arguments\": {\n" + - " \"temperature\": {\n" + - " \"type\": \"TS_ROLLING\",\n" + - " \"timeWindow\": {\n" + - " \"startTs\": 1739775630002,\n" + - " \"endTs\": 65432211,\n" + - " \"limit\": 5\n" + - " },\n" + - " \"values\": [\n" + - " { \"ts\": 1739775639851, \"value\": 23 },\n" + - " { \"ts\": 1739775664561, \"value\": 43 },\n" + - " { \"ts\": 1739775713079, \"value\": 15 },\n" + - " { \"ts\": 1739775999522, \"value\": 34 },\n" + - " { \"ts\": 1739776228452, \"value\": 22 }\n" + - " ]\n" + - " },\n" + - " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + - " }\n" + - "}" - + MARKDOWN_CODE_BLOCK_END - + "\n\n Expected result JSON contains \"output\" and \"error\"."; + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"expression\": \"var temp = 0; foreach(element: temperature.values) {temp += element.value;} var avgTemperature = temp / temperature.values.size(); var adjustedTemperature = avgTemperature + 0.1 * humidity.value; return {\\\"adjustedTemperature\\\": adjustedTemperature};\",\n" + + " \"arguments\": {\n" + + " \"temperature\": {\n" + + " \"type\": \"TS_ROLLING\",\n" + + " \"timeWindow\": {\n" + + " \"startTs\": 1739775630002,\n" + + " \"endTs\": 65432211,\n" + + " \"limit\": 5\n" + + " },\n" + + " \"values\": [\n" + + " { \"ts\": 1739775639851, \"value\": 23 },\n" + + " { \"ts\": 1739775664561, \"value\": 43 },\n" + + " { \"ts\": 1739775713079, \"value\": 15 },\n" + + " { \"ts\": 1739775999522, \"value\": 34 },\n" + + " { \"ts\": 1739776228452, \"value\": 22 }\n" + + " ]\n" + + " },\n" + + " \"humidity\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 23 }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Expected result JSON contains \"output\" and \"error\"."; @ApiOperation(value = "Create Or Update Calculated Field (saveCalculatedField)", notes = "Creates or Updates the Calculated Field. When creating calculated field, platform generates Calculated Field Id as " + UUID_WIKI_LINK + @@ -133,13 +131,12 @@ public class CalculatedFieldController extends BaseController { "Remove 'id', 'tenantId' from the request body example (below) to create new Calculated Field entity. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/calculatedField") public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.") @RequestBody CalculatedField calculatedField) throws Exception { calculatedField.setTenantId(getTenantId()); checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); - checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser()); + checkReferencedEntities(calculatedField.getConfiguration()); return tbCalculatedFieldService.save(calculatedField, getCurrentUser()); } @@ -147,8 +144,7 @@ public class CalculatedFieldController extends BaseController { notes = "Fetch the Calculated Field object based on the provided Calculated Field Id." ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping("/calculatedField/{calculatedFieldId}") public CalculatedField getCalculatedFieldById(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); @@ -162,8 +158,7 @@ public class CalculatedFieldController extends BaseController { notes = "Fetch the Calculated Fields based on the provided Entity Id." ) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}) public PageData getCalculatedFieldsByEntityId( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @@ -182,8 +177,8 @@ public class CalculatedFieldController extends BaseController { @ApiOperation(value = "Delete Calculated Field (deleteCalculatedField)", notes = "Deletes the calculated field. Referencing non-existing Calculated Field Id will cause an error." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/{calculatedFieldId}", method = RequestMethod.DELETE) - @ResponseStatus(value = HttpStatus.OK) + @DeleteMapping("/calculatedField/{calculatedFieldId}") + @ResponseStatus(HttpStatus.OK) public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); @@ -196,8 +191,7 @@ public class CalculatedFieldController extends BaseController { notes = "Gets latest calculated field debug event for specified calculated field id. " + "Referencing non-existing calculated field id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/{calculatedFieldId}/debug", method = RequestMethod.GET) - @ResponseBody + @GetMapping("/calculatedField/{calculatedFieldId}/debug") public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException { checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId); CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId)); @@ -212,15 +206,13 @@ public class CalculatedFieldController extends BaseController { @ApiOperation(value = "Test Script expression", notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/calculatedField/testScript", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/calculatedField/testScript") public JsonNode testScript( @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.") @RequestBody JsonNode inputParams) { String expression = inputParams.get("expression").asText(); Map arguments = Objects.requireNonNullElse( - JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() { - }), + JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}), Collections.emptyMap() ); @@ -231,12 +223,13 @@ public class CalculatedFieldController extends BaseController { String output = ""; String errorText = ""; + CalculatedFieldTbelScriptEngine engine = null; try { if (tbelInvokeService == null) { throw new IllegalArgumentException("TBEL script engine is disabled!"); } - CalculatedFieldScriptEngine calculatedFieldScriptEngine = new CalculatedFieldTbelScriptEngine( + engine = new CalculatedFieldTbelScriptEngine( getTenantId(), tbelInvokeService, expression, @@ -254,17 +247,20 @@ public class CalculatedFieldController extends BaseController { } } - JsonNode json = calculatedFieldScriptEngine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); + JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); output = JacksonUtil.toString(json); } catch (Exception e) { log.error("Error evaluating expression", e); - errorText = e.getMessage(); + Throwable rootCause = ExceptionUtils.getRootCause(e); + errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName()); + } finally { + if (engine != null) { + engine.destroy(); + } } - - ObjectNode result = JacksonUtil.newObjectNode(); - result.put("output", output); - result.put("error", errorText); - return result; + return JacksonUtil.newObjectNode() + .put("output", output) + .put("error", errorText); } private long getLatestTimestamp(Map arguments) { @@ -281,7 +277,7 @@ public class CalculatedFieldController extends BaseController { return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp; } - private & HasTenantId, I extends EntityId> void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig, SecurityUser user) throws ThingsboardException { + private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { List referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); for (EntityId referencedEntityId : referencedEntityIds) { EntityType entityType = referencedEntityId.getEntityType(); @@ -290,8 +286,7 @@ public class CalculatedFieldController extends BaseController { return; } case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> - throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); + default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 8817c24efe..a87864726b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -31,7 +31,7 @@ public class ControllerConstants { protected static final String ASSIGNEE_ID = "assigneeId"; protected static final String PAGE_DATA_PARAMETERS = "You can specify parameters to filter the results. " + "The result is wrapped with PageData object that allows you to iterate over result set using pagination. " + - "See the 'Model' tab of the Response Class for more details. "; + "See response schema for more details. "; protected static final String INLINE_IMAGES = "inlineImages"; protected static final String INLINE_IMAGES_DESCRIPTION = "Inline images as a data URL (Base64)"; @@ -90,6 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; + protected static final String AI_MODEL_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI model name, provider and model ID."; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index 3a93f318d1..70fa3a5ed0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.msg.edqs.EdqsApiService; import org.thingsboard.server.common.msg.edqs.EdqsService; import org.thingsboard.server.config.annotations.ApiOperation; @@ -76,6 +77,7 @@ public class EntityQueryController extends BaseController { @Parameter(description = "A JSON value representing the entity count query. See API call notes above for more details.") @RequestBody EntityCountQuery query) throws ThingsboardException { checkNotNull(query); + resolveQuery(query); return this.entityQueryService.countEntitiesByQuery(getCurrentUser(), query); } @@ -87,6 +89,7 @@ public class EntityQueryController extends BaseController { @Parameter(description = "A JSON value representing the entity data query. See API call notes above for more details.") @RequestBody EntityDataQuery query) throws ThingsboardException { checkNotNull(query); + resolveQuery(query); return this.entityQueryService.findEntityDataByQuery(getCurrentUser(), query); } @@ -103,6 +106,7 @@ public class EntityQueryController extends BaseController { if (assigneeId != null) { checkUserId(assigneeId, Operation.READ); } + resolveQuery(query); return this.entityQueryService.findAlarmDataByQuery(getCurrentUser(), query); } @@ -117,6 +121,7 @@ public class EntityQueryController extends BaseController { if (assigneeId != null) { checkUserId(assigneeId, Operation.READ); } + resolveQuery(query); return this.entityQueryService.countAlarmsByQuery(getCurrentUser(), query); } @@ -136,6 +141,7 @@ public class EntityQueryController extends BaseController { @RequestParam(value = "scope", required = false) String scope) throws ThingsboardException { TenantId tenantId = getTenantId(); checkNotNull(query); + resolveQuery(query); EntityDataPageLink pageLink = query.getPageLink(); if (pageLink.getPageSize() > MAX_PAGE_SIZE) { pageLink.setPageSize(MAX_PAGE_SIZE); @@ -155,4 +161,13 @@ public class EntityQueryController extends BaseController { return edqsService.getState(); } + private void resolveQuery(EntityCountQuery query) throws ThingsboardException { + if (query.getEntityFilter() != null) { + var user = getCurrentUser(); + var customerId = user.getCustomerId(); + var ownerId = customerId != null && !customerId.isNullUid() ? customerId : getTenantId(); + EntityFilter.resolveEntityFilter(query.getEntityFilter(), getTenantId(), user.getId(), ownerId); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java index 43ebf89b41..d4f932a643 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -24,13 +24,14 @@ import org.springframework.core.io.ByteArrayResource; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.thingsboard.server.common.data.OtaPackage; @@ -49,8 +50,6 @@ import org.thingsboard.server.service.entitiy.ota.TbOtaPackageService; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import java.io.IOException; - import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE; import static org.thingsboard.server.controller.ControllerConstants.DEVICE_PROFILE_ID_PARAM_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.OTA_PACKAGE_DESCRIPTION; @@ -80,8 +79,7 @@ public class OtaPackageController extends BaseController { @ApiOperation(value = "Download OTA Package (downloadOtaPackage)", notes = "Download OTA Package based on the provided OTA Package Id." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority( 'TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}/download", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackage/{otaPackageId}/download") public ResponseEntity downloadOtaPackage(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); @@ -105,8 +103,7 @@ public class OtaPackageController extends BaseController { notes = "Fetch the OTA Package Info object based on the provided OTA Package Id. " + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/otaPackage/info/{otaPackageId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackage/info/{otaPackageId}") public OtaPackageInfo getOtaPackageInfoById(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); @@ -118,8 +115,7 @@ public class OtaPackageController extends BaseController { notes = "Fetch the OTA Package object based on the provided OTA Package Id. " + "The server checks that the OTA Package is owned by the same tenant. " + OTA_PACKAGE_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackage/{otaPackageId}") public OtaPackage getOtaPackageById(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); @@ -134,10 +130,9 @@ public class OtaPackageController extends BaseController { "Referencing non-existing OTA Package Id will cause 'Not Found' error. " + "\n\nOTA Package combination of the title with the version is unique in the scope of tenant. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage", method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/otaPackage") public OtaPackageInfo saveOtaPackageInfo(@Parameter(description = "A JSON value representing the OTA Package.") - @RequestBody SaveOtaPackageInfoRequest otaPackageInfo) throws ThingsboardException { + @RequestBody SaveOtaPackageInfoRequest otaPackageInfo) throws Exception { otaPackageInfo.setTenantId(getTenantId()); checkEntity(otaPackageInfo.getId(), otaPackageInfo, Resource.OTA_PACKAGE); @@ -148,8 +143,7 @@ public class OtaPackageController extends BaseController { notes = "Update the OTA Package. Adds the date to the existing OTA Package Info" + TENANT_AUTHORITY_PARAGRAPH, requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(content = @Content(mediaType = MULTIPART_FORM_DATA_VALUE))) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.POST, consumes = MULTIPART_FORM_DATA_VALUE) - @ResponseBody + @PostMapping(value = "/otaPackage/{otaPackageId}", consumes = MULTIPART_FORM_DATA_VALUE) public OtaPackageInfo saveOtaPackageData(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable(OTA_PACKAGE_ID) String strOtaPackageId, @Parameter(description = "OTA Package checksum. For example, '0xd87f7e0c'") @@ -157,7 +151,7 @@ public class OtaPackageController extends BaseController { @Parameter(description = "OTA Package checksum algorithm.", schema = @Schema(allowableValues = {"MD5", "SHA256", "SHA384", "SHA512", "CRC32", "MURMUR3_32", "MURMUR3_128"})) @RequestParam(CHECKSUM_ALGORITHM) String checksumAlgorithmStr, @Parameter(description = "OTA Package data.") - @RequestPart MultipartFile file) throws ThingsboardException, IOException { + @RequestPart MultipartFile file) throws Exception { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); checkParameter(CHECKSUM_ALGORITHM, checksumAlgorithmStr); OtaPackageId otaPackageId = new OtaPackageId(toUUID(strOtaPackageId)); @@ -172,8 +166,7 @@ public class OtaPackageController extends BaseController { notes = "Returns a page of OTA Package Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/otaPackages", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackages") public PageData getOtaPackages(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @@ -192,8 +185,7 @@ public class OtaPackageController extends BaseController { notes = "Returns a page of OTA Package Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/otaPackages/{deviceProfileId}/{type}", method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/otaPackages/{deviceProfileId}/{type}") public PageData getOtaPackages(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("deviceProfileId") String strDeviceProfileId, @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) @@ -219,8 +211,7 @@ public class OtaPackageController extends BaseController { notes = "Deletes the OTA Package. Referencing non-existing OTA Package Id will cause an error. " + "Can't delete the OTA Package if it is referenced by existing devices or device profile." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/otaPackage/{otaPackageId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping(value = "/otaPackage/{otaPackageId}") public void deleteOtaPackage(@Parameter(description = OTA_PACKAGE_ID_PARAM_DESCRIPTION) @PathVariable("otaPackageId") String strOtaPackageId) throws ThingsboardException { checkParameter(OTA_PACKAGE_ID, strOtaPackageId); diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 1674db8755..efe3e61893 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; @@ -23,16 +22,19 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; @@ -155,9 +157,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chain (getRuleChainById)", notes = "Fetch the Rule Chain object based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}") public RuleChain getRuleChainById( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -169,9 +170,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chain output labels (getRuleChainOutputLabels)", notes = "Fetch the unique labels for the \"output\" Rule Nodes that belong to the Rule Chain based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/output/labels", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}/output/labels") public Set getRuleChainOutputLabels( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -184,9 +184,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get output labels usage (getRuleChainOutputLabelsUsage)", notes = "Fetch the list of rule chains and the relation types (labels) they use to process output of the current rule chain based on the provided Rule Chain Id. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/output/labels/usage", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}/output/labels/usage") public List getRuleChainOutputLabelsUsage( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -198,9 +197,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chain (getRuleChainById)", notes = "Fetch the Rule Chain Metadata object based on the provided Rule Chain Id. " + RULE_CHAIN_METADATA_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/metadata", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/{ruleChainId}/metadata") public RuleChainMetaData getRuleChainMetaData( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -218,9 +216,8 @@ public class RuleChainController extends BaseController { "\n\n" + RULE_CHAIN_DESCRIPTION + "Remove 'id', 'tenantId' from the request body example (below) to create new Rule Chain entity." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain") public RuleChain saveRuleChain( @Parameter(description = "A JSON value representing the rule chain.") @RequestBody RuleChain ruleChain) throws Exception { @@ -232,9 +229,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Create Default Rule Chain", notes = "Create rule chain from template, based on the specified name in the request. " + "Creates the rule chain based on the template that is used to create root rule chain. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/device/default", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/device/default") public RuleChain saveRuleChain( @Parameter(description = "A JSON value representing the request.") @RequestBody DefaultRuleChainCreateRequest request) throws Exception { @@ -245,9 +241,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Set Root Rule Chain (setRootRuleChain)", notes = "Makes the rule chain to be root rule chain. Updates previous root rule chain as well. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/root", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/{ruleChainId}/root") public RuleChain setRootRuleChain( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -259,9 +254,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Update Rule Chain Metadata", notes = "Updates the rule chain metadata. " + RULE_CHAIN_METADATA_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/metadata", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/metadata") public RuleChainMetaData saveRuleChainMetaData( @Parameter(description = "A JSON value representing the rule chain metadata.") @RequestBody RuleChainMetaData ruleChainMetaData, @@ -284,8 +278,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chains (getRuleChains)", notes = "Returns a page of Rule Chains owned by tenant. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChains", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/ruleChains", params = {"pageSize", "page"}) public PageData getRuleChains( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -302,7 +295,7 @@ public class RuleChainController extends BaseController { TenantId tenantId = getCurrentUser().getTenantId(); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); RuleChainType type = RuleChainType.CORE; - if (typeStr != null && typeStr.trim().length() > 0) { + if (StringUtils.isNotBlank(typeStr)) { type = RuleChainType.valueOf(typeStr); } return checkNotNull(ruleChainService.findTenantRuleChainsByType(tenantId, type, pageLink)); @@ -311,9 +304,9 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Delete rule chain (deleteRuleChain)", notes = "Deletes the rule chain. Referencing non-existing rule chain Id will cause an error. " + "Referencing rule chain that is used in the device profiles will cause an error." + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.DELETE) - @ResponseStatus(value = HttpStatus.OK) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/ruleChain/{ruleChainId}") + @ResponseStatus(HttpStatus.OK) public void deleteRuleChain( @Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { @@ -326,9 +319,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get latest input message (getLatestRuleNodeDebugInput)", notes = "Gets the input message from the debug events for specified Rule Chain Id. " + "Referencing non-existing rule chain Id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleNode/{ruleNodeId}/debugIn", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleNode/{ruleNodeId}/debugIn") public JsonNode getLatestRuleNodeDebugInput( @Parameter(description = RULE_NODE_ID_PARAM_DESCRIPTION) @PathVariable(RULE_NODE_ID) String strRuleNodeId) throws ThingsboardException { @@ -343,8 +335,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Is TBEL script executor enabled", notes = "Returns 'True' if the TBEL script execution is enabled" + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/tbelEnabled", method = RequestMethod.GET) - @ResponseBody + @GetMapping("/ruleChain/tbelEnabled") public Boolean isTbelEnabled() { return tbelEnabled; } @@ -352,13 +343,12 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Test Script function", notes = TEST_SCRIPT_FUNCTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/testScript", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/ruleChain/testScript") public JsonNode testScript( @Parameter(description = "Script language: JS or TBEL") @RequestParam(required = false) ScriptLanguage scriptLang, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test JS request. See API call description above.") - @RequestBody JsonNode inputParams) throws ThingsboardException, JsonProcessingException { + @RequestBody JsonNode inputParams) { String script = inputParams.get("script").asText(); String scriptType = inputParams.get("scriptType").asText(); JsonNode argNamesJson = inputParams.get("argNames"); @@ -366,8 +356,7 @@ public class RuleChainController extends BaseController { String data = inputParams.get("msg").asText(); JsonNode metadataJson = inputParams.get("metadata"); - Map metadata = JacksonUtil.convertValue(metadataJson, new TypeReference>() { - }); + Map metadata = JacksonUtil.convertValue(metadataJson, new TypeReference<>() {}); String msgType = inputParams.get("msgType").asText(); String output = ""; String errorText = ""; @@ -384,55 +373,40 @@ public class RuleChainController extends BaseController { } engine = new RuleNodeTbelScriptEngine(getTenantId(), tbelInvokeService, script, argNames); } - TbMsg inMsg = TbMsg.newMsg() + + var inMsg = TbMsg.newMsg() .type(msgType) .copyMetaData(new TbMsgMetaData(metadata)) .dataType(TbMsgDataType.JSON) .data(data) .build(); - switch (scriptType) { - case "update": - output = msgToOutput(engine.executeUpdateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); - break; - case "generate": - output = msgToOutput(engine.executeGenerateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); - break; - case "filter": - boolean result = engine.executeFilterAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - output = Boolean.toString(result); - break; - case "switch": - Set states = engine.executeSwitchAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - output = JacksonUtil.toString(states); - break; - case "json": - JsonNode json = engine.executeJsonAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - output = JacksonUtil.toString(json); - break; - case "string": - output = engine.executeToStringAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); - break; - default: - throw new IllegalArgumentException("Unsupported script type: " + scriptType); - } + + output = switch (scriptType) { + case "update" -> msgToOutput(engine.executeUpdateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "generate" -> msgToOutput(engine.executeGenerateAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "filter" -> Boolean.toString(engine.executeFilterAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "switch" -> JacksonUtil.toString(engine.executeSwitchAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "json" -> JacksonUtil.toString(engine.executeJsonAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS)); + case "string" -> engine.executeToStringAsync(inMsg).get(TIMEOUT, TimeUnit.SECONDS); + default -> throw new IllegalArgumentException("Unsupported script type: " + scriptType); + }; } catch (Exception e) { log.error("Error evaluating JS function", e); - errorText = e.getMessage(); + Throwable rootCause = ExceptionUtils.getRootCause(e); + errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName()); } finally { if (engine != null) { engine.destroy(); } } - ObjectNode result = JacksonUtil.newObjectNode(); - result.put("output", output); - result.put("error", errorText); - return result; + return JacksonUtil.newObjectNode() + .put("output", output) + .put("error", errorText); } @ApiOperation(value = "Export Rule Chains", notes = "Exports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChains/export", params = {"limit"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/ruleChains/export", params = {"limit"}) public RuleChainData exportRuleChains( @Parameter(description = "A limit of rule chains to export.", required = true) @RequestParam("limit") int limit) throws ThingsboardException { @@ -443,8 +417,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Import Rule Chains", notes = "Imports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChains/import", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/ruleChains/import") public List importRuleChains( @Parameter(description = "A JSON value representing the rule chains.") @RequestBody RuleChainData ruleChainData, @@ -454,12 +427,12 @@ public class RuleChainController extends BaseController { return ruleChainService.importTenantRuleChains(tenantId, ruleChainData, overwrite, tbRuleChainService::updateRuleNodeConfiguration); } - private String msgToOutput(TbMsg msg) throws Exception { + private String msgToOutput(TbMsg msg) { JsonNode resultNode = convertMsgToOut(msg); return JacksonUtil.toString(resultNode); } - private String msgToOutput(List msgs) throws Exception { + private String msgToOutput(List msgs) { JsonNode resultNode; if (msgs.size() > 1) { resultNode = JacksonUtil.newArrayNode(); @@ -473,7 +446,7 @@ public class RuleChainController extends BaseController { return JacksonUtil.toString(resultNode); } - private JsonNode convertMsgToOut(TbMsg msg) throws Exception { + private JsonNode convertMsgToOut(TbMsg msg) { ObjectNode msgData = JacksonUtil.newObjectNode(); if (!StringUtils.isEmpty(msg.getData())) { msgData.set("msg", JacksonUtil.toJsonNode(msg.getData())); @@ -492,8 +465,7 @@ public class RuleChainController extends BaseController { "Third, once rule chain will be delivered to edge service, it's going to start processing messages locally. " + "\n\nOnly rule chain with type 'EDGE' can be assigned to edge." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/ruleChain/{ruleChainId}", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/edge/{edgeId}/ruleChain/{ruleChainId}") public RuleChain assignRuleChainToEdge(@PathVariable("edgeId") String strEdgeId, @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); @@ -514,8 +486,7 @@ public class RuleChainController extends BaseController { EDGE_UNASSIGN_RECEIVE_STEP_DESCRIPTION + "Third, once 'unassign' command will be delivered to edge service, it's going to remove rule chain locally." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/ruleChain/{ruleChainId}", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping("/edge/{edgeId}/ruleChain/{ruleChainId}") public RuleChain unassignRuleChainFromEdge(@PathVariable("edgeId") String strEdgeId, @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter("edgeId", strEdgeId); @@ -530,9 +501,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Edge Rule Chains (getEdgeRuleChains)", notes = "Returns a page of Rule Chains assigned to the specified edge. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}) public PageData getEdgeRuleChains( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -557,9 +527,8 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Set Edge Template Root Rule Chain (setEdgeTemplateRootRuleChain)", notes = "Makes the rule chain to be root rule chain for any new edge that will be created. " + "Does not update root rule chain for already created edges. " + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/edgeTemplateRoot", method = RequestMethod.POST) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/ruleChain/{ruleChainId}/edgeTemplateRoot") public RuleChain setEdgeTemplateRootRuleChain(@Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter(RULE_CHAIN_ID, strRuleChainId); @@ -572,8 +541,7 @@ public class RuleChainController extends BaseController { notes = "Makes the rule chain to be automatically assigned for any new edge that will be created. " + "Does not assign this rule chain for already created edges. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/autoAssignToEdge", method = RequestMethod.POST) - @ResponseBody + @PostMapping("/ruleChain/{ruleChainId}/autoAssignToEdge") public RuleChain setAutoAssignToEdgeRuleChain(@Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter(RULE_CHAIN_ID, strRuleChainId); @@ -586,8 +554,7 @@ public class RuleChainController extends BaseController { notes = "Removes the rule chain from the list of rule chains that are going to be automatically assigned for any new edge that will be created. " + "Does not unassign this rule chain for already assigned edges. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/{ruleChainId}/autoAssignToEdge", method = RequestMethod.DELETE) - @ResponseBody + @DeleteMapping("/ruleChain/{ruleChainId}/autoAssignToEdge") public RuleChain unsetAutoAssignToEdgeRuleChain(@Parameter(description = RULE_CHAIN_ID_PARAM_DESCRIPTION) @PathVariable(RULE_CHAIN_ID) String strRuleChainId) throws ThingsboardException { checkParameter(RULE_CHAIN_ID, strRuleChainId); @@ -599,9 +566,8 @@ public class RuleChainController extends BaseController { // TODO: @voba refactor this - add new config to edge rule chain to set it as auto-assign @ApiOperation(value = "Get Auto Assign To Edge Rule Chains (getAutoAssignToEdgeRuleChains)", notes = "Returns a list of Rule Chains that will be assigned to a newly created edge. " + RULE_CHAIN_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/ruleChain/autoAssignToEdgeRuleChains", method = RequestMethod.GET) - @ResponseBody + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/ruleChain/autoAssignToEdgeRuleChains") public List getAutoAssignToEdgeRuleChains() throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List result = new ArrayList<>(); @@ -612,4 +578,5 @@ public class RuleChainController extends BaseController { } return checkNotNull(result); } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index bf9713f58e..c98ae0dcb8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -33,11 +33,11 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -65,25 +65,17 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; -import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DataType; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; -import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.IntervalType; -import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.timeseries.TimeseriesService; -import org.thingsboard.server.exception.InvalidParametersException; -import org.thingsboard.server.exception.UncheckedApiException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; @@ -156,9 +148,6 @@ public class TelemetryController extends BaseController { @Autowired private TbTelemetryService tbTelemetryService; - @Value("${transport.json.max_string_value_length:0}") - private int maxStringValueLength; - private ExecutorService executor; @PostConstruct @@ -314,10 +303,10 @@ public class TelemetryController extends BaseController { @Parameter(description = "A string value representing the timezone that will be used to calculate exact timestamps for 'WEEK', 'WEEK_ISO', 'MONTH' and 'QUARTER' interval types.") @RequestParam(name = "timeZone", required = false) String timeZone, @Parameter(description = "An integer value that represents a max number of time series data points to fetch." + - " This parameter is used only in the case if 'agg' parameter is set to 'NONE'.", schema = @Schema(defaultValue = "100")) + " This parameter is used only in the case if 'agg' parameter is set to 'NONE'.", schema = @Schema(defaultValue = "100")) @RequestParam(name = "limit", defaultValue = "100") Integer limit, @Parameter(description = "A string value representing the aggregation function. " + - "If the interval is not specified, 'agg' parameter will use 'NONE' value.", + "If the interval is not specified, 'agg' parameter will use 'NONE' value.", schema = @Schema(allowableValues = {"MIN", "MAX", "AVG", "SUM", "COUNT", "NONE"})) @RequestParam(name = "agg", defaultValue = "NONE") String aggStr, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @@ -337,20 +326,21 @@ public class TelemetryController extends BaseController { + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = SAVE_ATTIRIBUTES_STATUS_OK + - "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED', " + - "and also sends event msg to the rule engine with msg type 'ATTRIBUTES_UPDATED'."), + "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED', " + + "and also sends event msg to the rule engine with msg type 'ATTRIBUTES_UPDATED'."), @ApiResponse(responseCode = "400", description = SAVE_ATTIRIBUTES_STATUS_BAD_REQUEST), @ApiResponse(responseCode = "401", description = "User is not authorized to save device attributes for selected device. Most likely, User belongs to different Customer or Tenant."), @ApiResponse(responseCode = "500", description = "The exception was thrown during processing the request. " + - "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED' that includes an error stacktrace."), + "Platform creates an audit log event about device attributes updates with action type 'ATTRIBUTES_UPDATED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.POST) - @ResponseBody - public DeferredResult saveDeviceAttributes( - @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + @PostMapping(value = "/{deviceId}/{scope}") + public DeferredResult saveDeviceAttributes(@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("deviceId") String deviceIdStr, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) + @PathVariable("scope") AttributeScope scope, + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); return saveAttributes(getTenantId(), entityId, scope, request); } @@ -367,13 +357,15 @@ public class TelemetryController extends BaseController { @ApiResponse(responseCode = "500", description = SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/{scope}", method = RequestMethod.POST) - @ResponseBody - public DeferredResult saveEntityAttributesV1( - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + @PostMapping(value = "/{entityType}/{entityId}/{scope}") + public DeferredResult saveEntityAttributesV1(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) + @PathVariable("scope") AttributeScope scope, + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); } @@ -390,13 +382,15 @@ public class TelemetryController extends BaseController { @ApiResponse(responseCode = "500", description = SAVE_ENTITY_ATTRIBUTES_STATUS_INTERNAL_SERVER_ERROR), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/{entityType}/{entityId}/attributes/{scope}", method = RequestMethod.POST) - @ResponseBody - public DeferredResult saveEntityAttributesV2( - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody JsonNode request) throws ThingsboardException { + @PostMapping(value = "/{entityType}/{entityId}/attributes/{scope}") + public DeferredResult saveEntityAttributesV2(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) + @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) + @PathVariable("entityId") String entityIdStr, + @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) + @PathVariable("scope") AttributeScope scope, + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); } @@ -460,11 +454,11 @@ public class TelemetryController extends BaseController { TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Time series for the selected keys in the request was removed. " + - "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED'."), + "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED'."), @ApiResponse(responseCode = "400", description = "Platform returns a bad request in case if keys list is empty or start and end timestamp values is empty when deleteAllDataForKeys is set to false."), @ApiResponse(responseCode = "401", description = "User is not authorized to delete entity time series for selected entity. Most likely, User belongs to different Customer or Tenant."), @ApiResponse(responseCode = "500", description = "The exception was thrown during processing the request. " + - "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED' that includes an error stacktrace."), + "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE) @@ -541,11 +535,11 @@ public class TelemetryController extends BaseController { "Referencing a non-existing Device Id will cause an error" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Device attributes was removed for the selected keys in the request. " + - "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED'."), + "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED'."), @ApiResponse(responseCode = "400", description = "Platform returns a bad request in case if keys or scope are not specified."), @ApiResponse(responseCode = "401", description = "User is not authorized to delete device attributes for selected entity. Most likely, User belongs to different Customer or Tenant."), @ApiResponse(responseCode = "500", description = "The exception was thrown during processing the request. " + - "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), + "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.DELETE) @@ -563,11 +557,11 @@ public class TelemetryController extends BaseController { INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Entity attributes was removed for the selected keys in the request. " + - "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED'."), + "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED'."), @ApiResponse(responseCode = "400", description = "Platform returns a bad request in case if keys or scope are not specified."), @ApiResponse(responseCode = "401", description = "User is not authorized to delete entity attributes for selected entity. Most likely, User belongs to different Customer or Tenant."), @ApiResponse(responseCode = "500", description = "The exception was thrown during processing the request. " + - "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), + "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED' that includes an error stacktrace."), }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/{scope}", method = RequestMethod.DELETE) @@ -616,18 +610,24 @@ public class TelemetryController extends BaseController { }); } - private DeferredResult saveAttributes(TenantId srcTenantId, EntityId entityIdSrc, AttributeScope scope, JsonNode json) throws ThingsboardException { + private DeferredResult saveAttributes(TenantId srcTenantId, EntityId entityIdSrc, AttributeScope scope, String jsonStr) throws ThingsboardException { if (AttributeScope.SERVER_SCOPE != scope && AttributeScope.SHARED_SCOPE != scope) { return getImmediateDeferredResult("Invalid scope: " + scope, HttpStatus.BAD_REQUEST); } - if (json.isObject()) { - List attributes = extractRequestAttributes(json); + JsonElement json; + try { + json = JsonParser.parseString(jsonStr); + } catch (Exception e) { + return getImmediateDeferredResult("Invalid JSON", HttpStatus.BAD_REQUEST); + } + if (json.isJsonObject()) { + List attributes = JsonConverter.convertToAttributes(json); if (attributes.isEmpty()) { return getImmediateDeferredResult("No attributes data found in request body!", HttpStatus.BAD_REQUEST); } for (AttributeKvEntry attributeKvEntry : attributes) { - if (attributeKvEntry.getKey().isEmpty() || attributeKvEntry.getKey().trim().length() == 0) { - return getImmediateDeferredResult("Key cannot be empty or contains only spaces", HttpStatus.BAD_REQUEST); + if (attributeKvEntry.getKey().isBlank()) { + return getImmediateDeferredResult("Key cannot be blank", HttpStatus.BAD_REQUEST); } } SecurityUser user = getCurrentUser(); @@ -885,43 +885,6 @@ public class TelemetryController extends BaseController { return result; } - private List extractRequestAttributes(JsonNode jsonNode) { - long ts = System.currentTimeMillis(); - List attributes = new ArrayList<>(); - jsonNode.fields().forEachRemaining(entry -> { - String key = entry.getKey(); - JsonNode value = entry.getValue(); - if (entry.getValue().isObject() || entry.getValue().isArray()) { - attributes.add(new BaseAttributeKvEntry(new JsonDataEntry(key, toJsonStr(value)), ts)); - } else if (entry.getValue().isTextual()) { - if (maxStringValueLength > 0 && entry.getValue().textValue().length() > maxStringValueLength) { - String message = String.format("String value length [%d] for key [%s] is greater than maximum allowed [%d]", entry.getValue().textValue().length(), key, maxStringValueLength); - throw new UncheckedApiException(new InvalidParametersException(message)); - } - attributes.add(new BaseAttributeKvEntry(new StringDataEntry(key, value.textValue()), ts)); - } else if (entry.getValue().isBoolean()) { - attributes.add(new BaseAttributeKvEntry(new BooleanDataEntry(key, value.booleanValue()), ts)); - } else if (entry.getValue().isDouble()) { - attributes.add(new BaseAttributeKvEntry(new DoubleDataEntry(key, value.doubleValue()), ts)); - } else if (entry.getValue().isNumber()) { - if (entry.getValue().isBigInteger()) { - throw new UncheckedApiException(new InvalidParametersException("Big integer values are not supported!")); - } else { - attributes.add(new BaseAttributeKvEntry(new LongDataEntry(key, value.longValue()), ts)); - } - } - }); - return attributes; - } - - private String toJsonStr(JsonNode value) { - try { - return JacksonUtil.toString(value); - } catch (IllegalArgumentException e) { - throw new JsonParseException("Can't parse jsonValue: " + value, e); - } - } - private JsonNode toJsonNode(String value) { try { return JacksonUtil.toJsonNode(value); diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 5e5185ac8b..6765e95246 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,7 +116,6 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createDatabaseIndexes(); // TODO: cleanup update code after each release - systemDataLoaderService.updateDefaultNotificationConfigs(false); // Runs upgrade scripts that are not possible in plain SQL. dataUpdateService.updateData(); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java new file mode 100644 index 0000000000..9e00c8ddfd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelService.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ai; + +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; + +public interface AiChatModelService extends RuleEngineAiChatModelService {} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java new file mode 100644 index 0000000000..d6252f57a6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ai; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; + +@Service +@RequiredArgsConstructor +class AiChatModelServiceImpl implements AiChatModelService { + + private final Langchain4jChatModelConfigurer chatModelConfigurer; + private final AiRequestsExecutor aiRequestsExecutor; + + @Override + public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { + ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + return aiRequestsExecutor.sendChatRequestAsync(langChainChatModel, chatRequest); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java new file mode 100644 index 0000000000..75de36c36d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiRequestsExecutor.java @@ -0,0 +1,27 @@ +/** + * 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.ai; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; + +public interface AiRequestsExecutor { + + FluentFuture sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java new file mode 100644 index 0000000000..2d0121d30a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/DefaultAiRequestsExecutor.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ai; + +import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; +import org.thingsboard.common.util.ThingsBoardThreadFactory; + +import java.time.Duration; +import java.util.concurrent.Executors; + +@Lazy +@Component +@RequiredArgsConstructor +class DefaultAiRequestsExecutor implements AiRequestsExecutor { + + private final AiRequestsExecutorProperties properties; + + @Data + @Validated + @Configuration + @ConfigurationProperties(prefix = "actors.rule.ai-requests-thread-pool") + private static class AiRequestsExecutorProperties { + + @NotBlank(message = "Pool name must be not blank") + private String poolName = "ai-requests"; + + @Min(value = 1, message = "Pool size must be at least 1") + private int poolSize = 50; + + @Min(value = 1, message = "Termination timeout must be at least 1 second") + private int terminationTimeoutSeconds = 60; + + } + + private ListeningExecutorService executorService; + + @PostConstruct + private void init() { + executorService = MoreExecutors.listeningDecorator( + Executors.newFixedThreadPool(properties.getPoolSize(), ThingsBoardThreadFactory.forName(properties.getPoolName())) + ); + } + + @Override + public FluentFuture sendChatRequestAsync(ChatModel chatModel, ChatRequest chatRequest) { + return FluentFuture.from(executorService.submit(() -> chatModel.chat(chatRequest))); + } + + @PreDestroy + private void destroy() { + if (executorService != null) { + MoreExecutors.shutdownAndAwaitTermination(executorService, Duration.ofSeconds(properties.getTerminationTimeoutSeconds())); + executorService = null; + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java new file mode 100644 index 0000000000..69dd98f47f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -0,0 +1,269 @@ +/** + * 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.ai; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.api.gax.retrying.RetrySettings; +import com.google.auth.oauth2.ServiceAccountCredentials; +import com.google.cloud.vertexai.Transport; +import com.google.cloud.vertexai.VertexAI; +import com.google.cloud.vertexai.api.GenerationConfig; +import com.google.cloud.vertexai.api.PredictionServiceClient; +import com.google.cloud.vertexai.api.PredictionServiceSettings; +import com.google.cloud.vertexai.generativeai.GenerativeModel; +import dev.langchain4j.model.anthropic.AnthropicChatModel; +import dev.langchain4j.model.azure.AzureOpenAiChatModel; +import dev.langchain4j.model.bedrock.BedrockChatModel; +import dev.langchain4j.model.chat.ChatModel; +import dev.langchain4j.model.chat.request.ChatRequestParameters; +import dev.langchain4j.model.github.GitHubModelsChatModel; +import dev.langchain4j.model.googleai.GoogleAiGeminiChatModel; +import dev.langchain4j.model.mistralai.MistralAiChatModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.Langchain4jChatModelConfigurer; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Duration; + +@Component +class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigurer { + + @Override + public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { + return OpenAiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { + AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); + return AzureOpenAiChatModel.builder() + .endpoint(providerConfig.endpoint()) + .serviceVersion(providerConfig.serviceVersion()) + .apiKey(providerConfig.apiKey()) + .deploymentName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig) { + return GoogleAiGeminiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig) { + GoogleVertexAiGeminiProviderConfig providerConfig = chatModelConfig.providerConfig(); + + // construct service account credentials using service account key JSON + ServiceAccountCredentials serviceAccountCredentials; + try { + serviceAccountCredentials = ServiceAccountCredentials.fromStream(new ByteArrayInputStream(providerConfig.serviceAccountKey().getBytes())); + } catch (IOException e) { + throw new RuntimeException("Failed to parse service account key JSON", e); + } + + PredictionServiceSettings predictionServiceClientSettings; + try { + // create prediction service settings for REST transport with service account key credentials + PredictionServiceSettings.Builder settingsBuilder = PredictionServiceSettings.newHttpJsonBuilder() + .setCredentialsProvider(FixedCredentialsProvider.create(serviceAccountCredentials)); + + // get the retry settings that control request timeout for generateContent RPC + RetrySettings.Builder retrySettings = settingsBuilder + .generateContentSettings() + .getRetrySettings() + .toBuilder(); + + // set request timeout from model config + if (chatModelConfig.timeoutSeconds() != null) { + retrySettings.setTotalTimeout(org.threeten.bp.Duration.ofSeconds(chatModelConfig.timeoutSeconds())); + } + + // set updated retry settings + settingsBuilder.generateContentSettings().setRetrySettings(retrySettings.build()); + + // build the client settings + predictionServiceClientSettings = settingsBuilder.build(); + } catch (IOException e) { + throw new RuntimeException("Failed to create prediction service client settings", e); + } + + // construct Vertex AI instance + var vertexAI = new VertexAI.Builder() + .setProjectId(providerConfig.projectId()) + .setLocation(providerConfig.location()) + .setPredictionClientSupplier(() -> createPredictionServiceClient(predictionServiceClientSettings)) + .setTransport(Transport.REST) // GRPC also possible, but likely does not work with service account keys + .build(); + + // map model config to generation config + var generationConfigBuilder = GenerationConfig.newBuilder(); + if (chatModelConfig.temperature() != null) { + generationConfigBuilder.setTemperature(chatModelConfig.temperature().floatValue()); + } + if (chatModelConfig.topP() != null) { + generationConfigBuilder.setTopP(chatModelConfig.topP().floatValue()); + } + if (chatModelConfig.topK() != null) { + generationConfigBuilder.setTopK(chatModelConfig.topK()); + } + if (chatModelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); + } + if (chatModelConfig.frequencyPenalty() != null) { + generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); + } + if (chatModelConfig.maxOutputTokens() != null) { + generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); + } + var generationConfig = generationConfigBuilder.build(); + + // construct generative model instance + var generativeModel = new GenerativeModel(chatModelConfig.modelId(), vertexAI).withGenerationConfig(generationConfig); + + return new VertexAiGeminiChatModel(generativeModel, generationConfig, chatModelConfig.maxRetries()); + } + + private static PredictionServiceClient createPredictionServiceClient(PredictionServiceSettings settings) { + try { + return PredictionServiceClient.create(settings); + } catch (IOException e) { + throw new RuntimeException("Failed to create prediction service client", e); + } + } + + @Override + public ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig) { + return MistralAiChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig) { + return AnthropicChatModel.builder() + .apiKey(chatModelConfig.providerConfig().apiKey()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .topK(chatModelConfig.topK()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig) { + AmazonBedrockProviderConfig providerConfig = chatModelConfig.providerConfig(); + + var credentialsProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create(providerConfig.accessKeyId(), providerConfig.secretAccessKey()) + ); + + var bedrockClient = BedrockRuntimeClient.builder() + .region(Region.of(providerConfig.region())) + .credentialsProvider(credentialsProvider) + .build(); + + var defaultChatRequestParams = ChatRequestParameters.builder() + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .maxOutputTokens(chatModelConfig.maxOutputTokens()) + .build(); + + return BedrockChatModel.builder() + .client(bedrockClient) + .modelId(chatModelConfig.modelId()) + .defaultRequestParameters(defaultChatRequestParams) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + @Override + public ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig) { + return GitHubModelsChatModel.builder() + .gitHubToken(chatModelConfig.providerConfig().personalAccessToken()) + .modelName(chatModelConfig.modelId()) + .temperature(chatModelConfig.temperature()) + .topP(chatModelConfig.topP()) + .frequencyPenalty(chatModelConfig.frequencyPenalty()) + .presencePenalty(chatModelConfig.presencePenalty()) + .maxTokens(chatModelConfig.maxOutputTokens()) + .timeout(toDuration(chatModelConfig.timeoutSeconds())) + .maxRetries(chatModelConfig.maxRetries()) + .build(); + } + + private static Duration toDuration(Integer timeoutSeconds) { + return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java index 70c63a96cb..fda9a1d17b 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeEventSourcingListener.java @@ -113,7 +113,7 @@ public class EdgeEventSourcingListener { return; } try { - if (EntityType.TENANT.equals(entityType) || EntityType.EDGE.equals(entityType)) { + if (EntityType.TENANT == entityType || EntityType.EDGE == entityType || EntityType.AI_MODEL == 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: + case API_USAGE_STATE, EDGE, AI_MODEL: return false; case DOMAIN: if (entity instanceof Domain domain) { diff --git a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java index 61405e17e1..8a111e4d9d 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/RelatedEdgesSourcingListener.java @@ -58,8 +58,7 @@ public class RelatedEdgesSourcingListener { log.trace("[{}] ActionEntityEvent called: {}", event.getTenantId(), event); try { switch (event.getActionType()) { - case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> - relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); + case ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE -> relatedEdgesService.publishRelatedEdgeIdsEvictEvent(event.getTenantId(), event.getEntityId()); } } catch (Exception e) { log.error("[{}] failed to process ActionEntityEvent: {}", event.getTenantId(), event, e); @@ -67,7 +66,10 @@ public class RelatedEdgesSourcingListener { }); } - @TransactionalEventListener(fallbackExecution = true) + @TransactionalEventListener( + fallbackExecution = true, + condition = "#event.entityId.getEntityType() != T(org.thingsboard.server.common.data.EntityType).AI_MODEL" + ) public void handleEvent(DeleteEntityEvent event) { executorService.submit(() -> { log.trace("[{}] DeleteEntityEvent called: {}", event.getTenantId(), event); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java index eeaa80d6a2..068af492bc 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/telemetry/BaseTelemetryProcessor.java @@ -266,7 +266,7 @@ public abstract class BaseTelemetryProcessor extends BaseEdgeProcessor { SettableFuture futureToSet = SettableFuture.create(); JsonObject json = JsonUtils.getJsonObject(msg.getKvList()); AttributeScope scope = AttributeScope.valueOf(metaData.getValue(DataConstants.SCOPE)); - List attributes = new ArrayList<>(JsonConverter.convertToAttributes(json, ts)); + List attributes = JsonConverter.convertToAttributes(json, ts); ListenableFuture> future = filterAttributesByTs(tenantId, entityId, scope, attributes); Futures.addCallback(future, new FutureCallback<>() { @Override @@ -314,7 +314,7 @@ public abstract class BaseTelemetryProcessor extends BaseEdgeProcessor { SettableFuture futureToSet = SettableFuture.create(); JsonObject json = JsonUtils.getJsonObject(msg.getKvList()); AttributeScope scope = AttributeScope.valueOf(metaData.getValue(DataConstants.SCOPE)); - List attributes = new ArrayList<>(JsonConverter.convertToAttributes(json, ts)); + List attributes = JsonConverter.convertToAttributes(json, ts); ListenableFuture> future = filterAttributesByTs(tenantId, entityId, scope, attributes); Futures.addCallback(future, new FutureCallback<>() { @Override diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 476a4ef5ca..81fd1478b5 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -97,7 +97,7 @@ public abstract class AbstractTbEntityService { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } - protected ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + protected ListenableFuture autoCommit(User user, EntityId entityId) { if (vcService != null) { return vcService.autoCommit(user, entityId); } else { @@ -106,7 +106,7 @@ public abstract class AbstractTbEntityService { } } - protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + protected ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) { if (vcService != null) { return vcService.autoCommit(user, entityType, entityIds); } else { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java new file mode 100644 index 0000000000..264b82dd33 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelService.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.entitiy.ai; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.AbstractTbEntityService; + +import static java.util.Objects.requireNonNullElseGet; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +class DefaultTbAiModelService extends AbstractTbEntityService implements TbAiModelService { + + private final AiModelService aiModelService; + + @Override + public AiModel save(AiModel model, User user) { + var actionType = model.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + + var tenantId = user.getTenantId(); + model.setTenantId(tenantId); + + AiModel savedModel; + try { + savedModel = aiModelService.save(model); + autoCommit(user, savedModel.getId()); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, requireNonNullElseGet(model.getId(), () -> emptyId(EntityType.AI_MODEL)), model, actionType, user, e); + throw e; + } + + logEntityActionService.logEntityAction(tenantId, savedModel.getId(), savedModel, actionType, user); + + return savedModel; + } + + @Override + public boolean delete(AiModel model, User user) { + var actionType = ActionType.DELETED; + + var tenantId = user.getTenantId(); + var modelId = model.getId(); + + boolean deleted; + try { + deleted = aiModelService.deleteByTenantIdAndId(tenantId, modelId); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, e, modelId.toString()); + throw e; + } + + if (deleted) { + logEntityActionService.logEntityAction(tenantId, modelId, model, actionType, user, modelId.toString()); + } + + return deleted; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java new file mode 100644 index 0000000000..0b09423ffa --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ai/TbAiModelService.java @@ -0,0 +1,27 @@ +/** + * 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.entitiy.ai; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; + +public interface TbAiModelService { + + AiModel save(AiModel model, User user); + + boolean delete(AiModel model, User user); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java index de4d0ac9cb..73f6e0a861 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/alarm/DefaultTbAlarmCommentService.java @@ -32,7 +32,7 @@ import org.thingsboard.server.service.entitiy.AbstractTbEntityService; @Service @AllArgsConstructor -public class DefaultTbAlarmCommentService extends AbstractTbEntityService implements TbAlarmCommentService{ +public class DefaultTbAlarmCommentService extends AbstractTbEntityService implements TbAlarmCommentService { @Autowired private AlarmCommentService alarmCommentService; @@ -68,4 +68,5 @@ public class DefaultTbAlarmCommentService extends AbstractTbEntityService implem throw new ThingsboardException("System comment could not be deleted", ThingsboardErrorCode.BAD_REQUEST_PARAMS); } } + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java index af8bbeb669..2d597f7053 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java @@ -110,4 +110,5 @@ public class DefaultTbOtaPackageService extends AbstractTbEntityService implemen throw e; } } + } diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsDeletionTaskProcessor.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsDeletionTaskProcessor.java index 55107a8326..1c7d312e8b 100644 --- a/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsDeletionTaskProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/processor/AlarmsDeletionTaskProcessor.java @@ -43,33 +43,30 @@ public class AlarmsDeletionTaskProcessor extends HousekeeperTaskProcessor> alarms = alarmService.findAlarmIdsByOriginatorId(tenantId, entityId, lastCreatedTime, lastId, 128); - if (alarms.isEmpty()) { - break; - } + if (task.getAlarms() == null) { + AlarmId lastId = null; + long lastCreatedTime = 0; + while (true) { + List> alarms = alarmService.findAlarmIdsByOriginatorId(tenantId, entityId, lastCreatedTime, lastId, 128); + if (alarms.isEmpty()) { + break; + } - housekeeperClient.submitTask(new AlarmsDeletionHousekeeperTask(tenantId, entityId, alarms.stream().map(TbPair::getFirst).toList())); + housekeeperClient.submitTask(new AlarmsDeletionHousekeeperTask(tenantId, entityId, alarms.stream().map(TbPair::getFirst).toList())); - TbPair last = alarms.get(alarms.size() - 1); - lastId = new AlarmId(last.getFirst()); - lastCreatedTime = last.getSecond(); - log.debug("[{}][{}][{}] Submitted task for deleting {} alarms", tenantId, entityType, entityId, alarms.size()); - } - } else { - for (UUID alarmId : task.getAlarms()) { - alarmService.delAlarm(tenantId, new AlarmId(alarmId)); - } - log.debug("[{}][{}][{}] Deleted {} alarms", tenantId, entityType, entityId, task.getAlarms().size()); + TbPair last = alarms.get(alarms.size() - 1); + lastId = new AlarmId(last.getFirst()); + lastCreatedTime = last.getSecond(); + log.debug("[{}][{}][{}] Submitted task for deleting {} alarms", tenantId, entityType, entityId, alarms.size()); } + int count = alarmService.deleteEntityAlarmRecords(tenantId, entityId); + log.debug("[{}][{}][{}] Deleted {} entity alarms", tenantId, entityType, entityId, count); + } else { + for (UUID alarmId : task.getAlarms()) { + alarmService.delAlarm(tenantId, new AlarmId(alarmId)); + } + log.debug("[{}][{}][{}] Deleted {} alarms", tenantId, entityType, entityId, task.getAlarms().size()); } - - int count = alarmService.deleteEntityAlarmRecords(tenantId, entityId); - log.debug("[{}][{}][{}] Deleted {} entity alarms", tenantId, entityType, entityId, count); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index ad87242c0a..e5bd026fb7 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -32,7 +32,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // This list should include all versions which are compatible for the upgrade. // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.0.0", "4.0.1", "4.0.2"); + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.1.0"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java index c3f1cee046..972d5ff36c 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java @@ -20,25 +20,17 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.collect.Lists; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.alarm.AlarmSeverity; -import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.FilterPredicateValue; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.data.relation.RelationTypeGroup; -import org.thingsboard.server.common.data.rule.RuleNode; -import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; -import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; -import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.component.RuleNodeClassInfo; import org.thingsboard.server.service.install.DbUpgradeExecutorService; @@ -46,108 +38,27 @@ import org.thingsboard.server.utils.TbNodeUpgradeUtils; import java.util.ArrayList; import java.util.List; -import java.util.Optional; -import java.util.UUID; import java.util.concurrent.ExecutionException; -import static org.thingsboard.server.dao.rule.BaseRuleChainService.TB_RULE_CHAIN_INPUT_NODE; - @Service @Profile("install") @Slf4j +@RequiredArgsConstructor public class DefaultDataUpdateService implements DataUpdateService { private static final int MAX_PENDING_SAVE_RULE_NODE_FUTURES = 256; private static final int DEFAULT_PAGE_SIZE = 1024; - @Autowired - private RuleChainService ruleChainService; - - @Autowired - private RelationService relationService; - - @Autowired - private ComponentDiscoveryService componentDiscoveryService; - - @Autowired - private DbUpgradeExecutorService executorService; - - @Autowired - private TenantProfileService tenantProfileService; + private final RuleChainService ruleChainService; + private final ComponentDiscoveryService componentDiscoveryService; + private final DbUpgradeExecutorService executorService; @Override public void updateData() throws Exception { log.info("Updating data ..."); //TODO: should be cleaned after each release - updateInputNodes(); - deduplicateRateLimitsPerSecondsConfigurations(); - log.info("Data updated."); - } - - private void deduplicateRateLimitsPerSecondsConfigurations() { - log.info("Starting update of tenant profiles..."); - int totalProfiles = 0; - int updatedTenantProfiles = 0; - int skippedProfiles = 0; - int failedProfiles = 0; - - var tenantProfiles = new PageDataIterable<>( - pageLink -> tenantProfileService.findTenantProfiles(TenantId.SYS_TENANT_ID, pageLink), 1024); - - for (TenantProfile tenantProfile : tenantProfiles) { - totalProfiles++; - String profileName = tenantProfile.getName(); - UUID profileId = tenantProfile.getId().getId(); - try { - Optional profileConfiguration = tenantProfile.getProfileConfiguration(); - if (profileConfiguration.isEmpty()) { - log.debug("[{}][{}] Skipping tenant profile with non-default configuration.", profileId, profileName); - skippedProfiles++; - continue; - } - - DefaultTenantProfileConfiguration defaultTenantProfileConfiguration = profileConfiguration.get(); - defaultTenantProfileConfiguration.deduplicateRateLimitsConfigs(); - tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile); - updatedTenantProfiles++; - log.debug("[{}][{}] Successfully updated tenant profile.", profileId, profileName); - } catch (Exception e) { - log.error("[{}][{}] Failed to updated tenant profile: ", profileId, profileName, e); - failedProfiles++; - } - } - - log.info("Tenant profiles update completed. Total: {}, Updated: {}, Skipped: {}, Failed: {}", - totalProfiles, updatedTenantProfiles, skippedProfiles, failedProfiles); - } - - - private void updateInputNodes() { - log.info("Creating relations for input nodes..."); - int n = 0; - var inputNodes = new PageDataIterable<>(pageLink -> ruleChainService.findAllRuleNodesByType(TB_RULE_CHAIN_INPUT_NODE, pageLink), 1024); - for (RuleNode inputNode : inputNodes) { - try { - RuleChainId targetRuleChainId = Optional.ofNullable(inputNode.getConfiguration().get("ruleChainId")) - .filter(JsonNode::isTextual).map(JsonNode::asText).map(id -> new RuleChainId(UUID.fromString(id))) - .orElse(null); - if (targetRuleChainId == null) { - continue; - } - - EntityRelation relation = new EntityRelation(); - relation.setFrom(inputNode.getRuleChainId()); - relation.setTo(targetRuleChainId); - relation.setType(EntityRelation.USES_TYPE); - relation.setTypeGroup(RelationTypeGroup.COMMON); - relationService.saveRelation(TenantId.SYS_TENANT_ID, relation); - n++; - } catch (Exception e) { - log.error("Failed to save relation for input node: {}", inputNode, e); - } - } - log.info("Created {} relations for input nodes", n); + log.info("Data updated."); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 99faf1a1d0..c1cfe791da 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -22,9 +22,9 @@ import freemarker.template.Template; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Lazy; @@ -64,55 +64,37 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -@Service @Slf4j +@Service +@RequiredArgsConstructor public class DefaultMailService implements MailService { - public static final String TARGET_EMAIL = "targetEmail"; - public static final String UTF_8 = "UTF-8"; + private static final String TARGET_EMAIL = "targetEmail"; + private static final String UTF_8 = "UTF-8"; + private static final long DEFAULT_TIMEOUT = 10_000; + + private final ScheduledExecutorService timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); private final MessageSource messages; private final Configuration freemarkerConfig; private final AdminSettingsService adminSettingsService; private final TbApiUsageReportClient apiUsageClient; - - private static final long DEFAULT_TIMEOUT = 10_000; - @Lazy - @Autowired - private TbApiUsageStateService apiUsageStateService; - - @Autowired - private MailSenderInternalExecutorService mailExecutorService; - - @Autowired - private PasswordResetExecutorService passwordResetExecutorService; - - @Autowired - private TbMailContextComponent ctx; - - @Autowired - private RateLimitService rateLimitService; + private final TbApiUsageStateService apiUsageStateService; + private final MailSenderInternalExecutorService mailExecutorService; + private final PasswordResetExecutorService passwordResetExecutorService; + private final TbMailContextComponent ctx; + private final RateLimitService rateLimitService; @Value("${mail.per_tenant_rate_limits:}") private String perTenantRateLimitConfig; - private final ScheduledExecutorService timeoutScheduler; - private TbMailSender mailSender; private String mailFrom; private long timeout; - public DefaultMailService(MessageSource messages, Configuration freemarkerConfig, AdminSettingsService adminSettingsService, TbApiUsageReportClient apiUsageClient) { - this.messages = messages; - this.freemarkerConfig = freemarkerConfig; - this.adminSettingsService = adminSettingsService; - this.apiUsageClient = apiUsageClient; - this.timeoutScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("mail-service-watchdog"); - } - @PostConstruct private void init() { updateMailConfiguration(); @@ -120,9 +102,7 @@ public class DefaultMailService implements MailService { @PreDestroy public void destroy() { - if (timeoutScheduler != null) { - timeoutScheduler.shutdownNow(); - } + timeoutScheduler.shutdownNow(); } @Override @@ -311,22 +291,21 @@ public class DefaultMailService implements MailService { model.put("apiFeature", apiFeature.getLabel()); model.put(TARGET_EMAIL, email); - String message = null; - - switch (stateValue) { - case ENABLED: + String message = switch (stateValue) { + case ENABLED -> { model.put("apiLabel", toEnabledValueLabel(apiFeature)); - message = mergeTemplateIntoString("state.enabled.ftl", model); - break; - case WARNING: + yield mergeTemplateIntoString("state.enabled.ftl", model); + } + case WARNING -> { model.put("apiValueLabel", toDisabledValueLabel(apiFeature) + " " + toWarningValueLabel(recordState)); - message = mergeTemplateIntoString("state.warning.ftl", model); - break; - case DISABLED: + yield mergeTemplateIntoString("state.warning.ftl", model); + } + case DISABLED -> { model.put("apiLimitValueLabel", toDisabledValueLabel(apiFeature) + " " + toDisabledValueLabel(recordState)); - message = mergeTemplateIntoString("state.disabled.ftl", model); - break; - } + yield mergeTemplateIntoString("state.disabled.ftl", model); + } + }; + sendMail(mailSender, mailFrom, email, subject, message, timeout); } @@ -341,89 +320,55 @@ public class DefaultMailService implements MailService { } private String toEnabledValueLabel(ApiFeature apiFeature) { - switch (apiFeature) { - case DB: - return "save"; - case TRANSPORT: - return "receive"; - case JS: - return "invoke"; - case RE: - return "process"; - case EMAIL: - case SMS: - return "send"; - case ALARM: - return "create"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (apiFeature) { + case DB -> "save"; + case TRANSPORT -> "receive"; + case JS -> "invoke"; + case RE -> "process"; + case EMAIL, SMS -> "send"; + case ALARM -> "create"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toDisabledValueLabel(ApiFeature apiFeature) { - switch (apiFeature) { - case DB: - return "saved"; - case TRANSPORT: - return "received"; - case JS: - return "invoked"; - case RE: - return "processed"; - case EMAIL: - case SMS: - return "sent"; - case ALARM: - return "created"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (apiFeature) { + case DB -> "saved"; + case TRANSPORT -> "received"; + case JS -> "invoked"; + case RE -> "processed"; + case EMAIL, SMS -> "sent"; + case ALARM -> "created"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toWarningValueLabel(ApiUsageRecordState recordState) { String valueInM = recordState.getValueAsString(); String thresholdInM = recordState.getThresholdAsString(); - switch (recordState.getKey()) { - case STORAGE_DP_COUNT: - case TRANSPORT_DP_COUNT: - return valueInM + " out of " + thresholdInM + " allowed data points"; - case TRANSPORT_MSG_COUNT: - return valueInM + " out of " + thresholdInM + " allowed messages"; - case JS_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; - case TBEL_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Tbel functions"; - case RE_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; - case EMAIL_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed Email messages"; - case SMS_EXEC_COUNT: - return valueInM + " out of " + thresholdInM + " allowed SMS messages"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (recordState.getKey()) { + case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> valueInM + " out of " + thresholdInM + " allowed data points"; + case TRANSPORT_MSG_COUNT -> valueInM + " out of " + thresholdInM + " allowed messages"; + case JS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed JavaScript functions"; + case TBEL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Tbel functions"; + case RE_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Rule Engine messages"; + case EMAIL_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed Email messages"; + case SMS_EXEC_COUNT -> valueInM + " out of " + thresholdInM + " allowed SMS messages"; + default -> throw new RuntimeException("Not implemented!"); + }; } private String toDisabledValueLabel(ApiUsageRecordState recordState) { - switch (recordState.getKey()) { - case STORAGE_DP_COUNT: - case TRANSPORT_DP_COUNT: - return recordState.getValueAsString() + " data points"; - case TRANSPORT_MSG_COUNT: - return recordState.getValueAsString() + " messages"; - case JS_EXEC_COUNT: - return "JavaScript functions " + recordState.getValueAsString() + " times"; - case TBEL_EXEC_COUNT: - return "TBEL functions " + recordState.getValueAsString() + " times"; - case RE_EXEC_COUNT: - return recordState.getValueAsString() + " Rule Engine messages"; - case EMAIL_EXEC_COUNT: - return recordState.getValueAsString() + " Email messages"; - case SMS_EXEC_COUNT: - return recordState.getValueAsString() + " SMS messages"; - default: - throw new RuntimeException("Not implemented!"); - } + return switch (recordState.getKey()) { + case STORAGE_DP_COUNT, TRANSPORT_DP_COUNT -> recordState.getValueAsString() + " data points"; + case TRANSPORT_MSG_COUNT -> recordState.getValueAsString() + " messages"; + case JS_EXEC_COUNT -> "JavaScript functions " + recordState.getValueAsString() + " times"; + case TBEL_EXEC_COUNT -> "TBEL functions " + recordState.getValueAsString() + " times"; + case RE_EXEC_COUNT -> recordState.getValueAsString() + " Rule Engine messages"; + case EMAIL_EXEC_COUNT -> recordState.getValueAsString() + " Email messages"; + case SMS_EXEC_COUNT -> recordState.getValueAsString() + " SMS messages"; + default -> throw new RuntimeException("Not implemented!"); + }; } private void sendMail(JavaMailSenderImpl mailSender, String mailFrom, String email, diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java index 8d7356a507..914a53e8a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailSender.java @@ -25,6 +25,7 @@ import com.google.api.client.http.javanet.NetHttpTransport; import com.google.api.client.json.gson.GsonFactory; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.lang.Nullable; import org.springframework.mail.MailException; @@ -50,8 +51,10 @@ public class TbMailSender extends JavaMailSenderImpl { private final TbMailContextComponent ctx; private final Lock lock; + @Getter private final Boolean oauth2Enabled; private volatile String accessToken; + @Getter private volatile long tokenExpires; public TbMailSender(TbMailContextComponent ctx, JsonNode jsonConfig) { @@ -70,14 +73,6 @@ public class TbMailSender extends JavaMailSenderImpl { setJavaMailProperties(createJavaMailProperties(jsonConfig)); } - public Boolean getOauth2Enabled() { - return oauth2Enabled; - } - - public long getTokenExpires() { - return tokenExpires; - } - @Override protected void doSend(MimeMessage[] mimeMessages, @Nullable Object[] originalMessages) throws MailException { updateOauth2PasswordIfExpired(); @@ -98,8 +93,8 @@ public class TbMailSender extends JavaMailSenderImpl { super.testConnection(); } - public void updateOauth2PasswordIfExpired() { - if (getOauth2Enabled() && (System.currentTimeMillis() > getTokenExpires())){ + public void updateOauth2PasswordIfExpired() { + if (getOauth2Enabled() && (System.currentTimeMillis() > getTokenExpires())) { refreshAccessToken(); setPassword(accessToken); } @@ -168,8 +163,8 @@ public class TbMailSender extends JavaMailSenderImpl { .setClientAuthentication(new ClientParametersAuthentication(clientId, clientSecret)) .execute(); if (MailOauth2Provider.OFFICE_365.name().equals(providerId)) { - ((ObjectNode)jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); - ((ObjectNode)jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); + ((ObjectNode) jsonValue).put("refreshToken", tokenResponse.getRefreshToken()); + ((ObjectNode) jsonValue).put("refreshTokenExpires", Instant.now().plus(Duration.ofDays(AZURE_DEFAULT_REFRESH_TOKEN_LIFETIME_IN_DAYS)).toEpochMilli()); ctx.getAdminSettingsService().saveAdminSettings(TenantId.SYS_TENANT_ID, settings); } accessToken = tokenResponse.getAccessToken(); @@ -190,4 +185,5 @@ public class TbMailSender extends JavaMailSenderImpl { throw new IncorrectParameterException(String.format("Invalid smtp port value: %s", strPort)); } } -} \ No newline at end of file + +} diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index f90c90df54..b3fe76f154 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -208,9 +208,26 @@ public class DefaultEntityQueryService implements EntityQueryService { @Override public long countAlarmsByQuery(SecurityUser securityUser, AlarmCountQuery query) { + if (query.getEntityFilter() != null) { + EntityDataQuery entityDataQuery = this.buildEntityDataQuery(query); + PageData entities = entityService.findEntityDataByQuery(securityUser.getTenantId(), + securityUser.getCustomerId(), entityDataQuery); + if (entities.getTotalElements() > 0) { + List entityIds = entities.getData().stream().map(EntityData::getEntityId).toList(); + return alarmService.countAlarmsByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query, entityIds); + } else { + return 0; + } + } return alarmService.countAlarmsByQuery(securityUser.getTenantId(), securityUser.getCustomerId(), query); } + private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { + EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, + new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, ModelConstants.CREATED_TIME_PROPERTY))); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + } + private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityDataSortOrder entitiesSortOrder; @@ -220,7 +237,7 @@ public class DefaultEntityQueryService implements EntityQueryService { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java index 9d20b4d74e..265f14c4e2 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbClusterService.java @@ -583,17 +583,18 @@ public class DefaultTbClusterService implements TbClusterService { TbQueueProducer> toRuleEngineProducer = producerProvider.getRuleEngineNotificationsMsgProducer(); Set tbRuleEngineServices = partitionService.getAllServiceIds(ServiceType.TB_RULE_ENGINE); EntityType entityType = msg.getEntityId().getEntityType(); - if (entityType.equals(EntityType.TENANT) - || entityType.equals(EntityType.TENANT_PROFILE) - || entityType.equals(EntityType.DEVICE_PROFILE) - || (entityType.equals(EntityType.ASSET) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) - || entityType.equals(EntityType.ASSET_PROFILE) - || entityType.equals(EntityType.API_USAGE_STATE) - || (entityType.equals(EntityType.DEVICE) && msg.getEvent() == ComponentLifecycleEvent.UPDATED) - || entityType.equals(EntityType.ENTITY_VIEW) - || entityType.equals(EntityType.NOTIFICATION_RULE) - || entityType.equals(EntityType.CALCULATED_FIELD) - || entityType.equals(EntityType.JOB) + if (entityType.isOneOf( + EntityType.TENANT, + EntityType.API_USAGE_STATE, + EntityType.ENTITY_VIEW, + EntityType.NOTIFICATION_RULE, + EntityType.CALCULATED_FIELD, + EntityType.TENANT_PROFILE, + EntityType.DEVICE_PROFILE, + EntityType.ASSET_PROFILE, + EntityType.JOB) + || (entityType == EntityType.ASSET && msg.getEvent() == ComponentLifecycleEvent.UPDATED) + || (entityType == EntityType.DEVICE && msg.getEvent() == ComponentLifecycleEvent.UPDATED) ) { TbQueueProducer> toCoreNfProducer = producerProvider.getTbCoreNotificationsMsgProducer(); Set tbCoreServices = partitionService.getAllServiceIds(ServiceType.TB_CORE); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java index 9a34fa08df..111234500f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/Oauth2AuthenticationSuccessHandler.java @@ -121,7 +121,7 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS errorPrefix = "/login?loginError="; } getRedirectStrategy().sendRedirect(request, response, baseUrl + errorPrefix + - URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8.toString())); + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8)); } } @@ -138,4 +138,5 @@ public class Oauth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationS } return baseUrl + "accessToken=" + tokenPair.getToken() + "&refreshToken=" + tokenPair.getRefreshToken(); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 2a92c040e3..8a4208c457 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -21,7 +21,8 @@ import java.util.Collections; import java.util.Set; public enum Resource { - ADMIN_SETTINGS(), + + ADMIN_SETTINGS(EntityType.ADMIN_SETTINGS), ALARM(EntityType.ALARM), DEVICE(EntityType.DEVICE), ASSET(EntityType.ASSET), @@ -51,7 +52,8 @@ public enum Resource { NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), MOBILE_APP_SETTINGS, - JOB(EntityType.JOB); + JOB(EntityType.JOB), + AI_MODEL(EntityType.AI_MODEL); private final Set entityTypes; @@ -75,4 +77,5 @@ public enum Resource { } throw new IllegalArgumentException("Unknown EntityType: " + entityType.name()); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index 58023be34d..7a824ca735 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -18,6 +18,8 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; @@ -56,6 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); put(Resource.JOB, tenantEntityPermissionChecker); + put(Resource.AI_MODEL, aiModelPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -146,4 +149,18 @@ public class TenantAdminPermissions extends AbstractPermissions { }; + private static final PermissionChecker aiModelPermissionChecker = new PermissionChecker<>() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, AiModelId entityId, AiModel entity) { + return user.getTenantId().equals(entity.getTenantId()); + } + + }; + } diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java index 5522047deb..475c9c2c75 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsSenderFactory.java @@ -31,16 +31,12 @@ public class DefaultSmsSenderFactory implements SmsSenderFactory { @Override public SmsSender createSmsSender(SmsProviderConfiguration config) { - switch (config.getType()) { - case AWS_SNS: - return new AwsSmsSender((AwsSnsSmsProviderConfiguration)config); - case TWILIO: - return new TwilioSmsSender((TwilioSmsProviderConfiguration)config); - case SMPP: - return new SmppSmsSender((SmppSmsProviderConfiguration) config); - default: - throw new RuntimeException("Unknown SMS provider type " + config.getType()); - } + return switch (config.getType()) { + case AWS_SNS -> new AwsSmsSender((AwsSnsSmsProviderConfiguration) config); + case TWILIO -> new TwilioSmsSender((TwilioSmsProviderConfiguration) config); + case SMPP -> new SmppSmsSender((SmppSmsProviderConfiguration) config); + default -> throw new RuntimeException("Unknown SMS provider type " + config.getType()); + }; } } diff --git a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java index 779a3adfa6..a313dcb9a6 100644 --- a/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java +++ b/application/src/main/java/org/thingsboard/server/service/sms/DefaultSmsService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.sms; import com.fasterxml.jackson.databind.JsonNode; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.NestedRuntimeException; import org.springframework.stereotype.Service; @@ -37,8 +38,9 @@ import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -@Service @Slf4j +@Service +@RequiredArgsConstructor public class DefaultSmsService implements SmsService { private final SmsSenderFactory smsSenderFactory; @@ -48,13 +50,6 @@ public class DefaultSmsService implements SmsService { private SmsSender smsSender; - public DefaultSmsService(SmsSenderFactory smsSenderFactory, AdminSettingsService adminSettingsService, TbApiUsageStateService apiUsageStateService, TbApiUsageReportClient apiUsageClient) { - this.smsSenderFactory = smsSenderFactory; - this.adminSettingsService = adminSettingsService; - this.apiUsageStateService = apiUsageStateService; - this.apiUsageClient = apiUsageClient; - } - @PostConstruct private void init() { updateSmsConfiguration(); @@ -148,4 +143,5 @@ public class DefaultSmsService implements SmsService { return new ThingsboardException(String.format("Unable to send SMS: %s", message), ThingsboardErrorCode.GENERAL); } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java index 9d51c2ec4a..b471bf418a 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractEntityQuerySubCtx.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.EntityCountQuery; +import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.FilterPredicateType; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.KeyFilterPredicate; @@ -94,9 +95,14 @@ public abstract class TbAbstractEntityQuerySubCtx ex public void setAndResolveQuery(T query) { dynamicValues.clear(); this.query = query; - if (query != null && query.getKeyFilters() != null) { - for (KeyFilter filter : query.getKeyFilters()) { - registerDynamicValues(filter.getPredicate()); + if (query != null) { + if (query.getEntityFilter() != null) { + EntityFilter.resolveEntityFilter(query.getEntityFilter(), getTenantId(), getUserId(), getOwnerId()); + } + if (query.getKeyFilters() != null) { + for (KeyFilter filter : query.getKeyFilters()) { + registerDynamicValues(filter.getPredicate()); + } } } resolve(getTenantId(), getCustomerId(), getUserId()); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java index 6d4b6e8ed3..70bd34886a 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAbstractSubCtx.java @@ -107,6 +107,11 @@ public abstract class TbAbstractSubCtx { return sessionRef.getSecurityCtx().getId(); } + public EntityId getOwnerId() { + var customerId = getCustomerId(); + return customerId != null && !customerId.isNullUid() ? customerId : getTenantId(); + } + public void sendWsMsg(CmdUpdate update) { wsLock.lock(); try { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index db7e37b368..506b48bf8c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -67,9 +67,10 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS protected static final List SUPPORTED_ENTITY_TYPES = List.of( EntityType.CUSTOMER, EntityType.RULE_CHAIN, EntityType.TB_RESOURCE, EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET, - EntityType.DEVICE_PROFILE, EntityType.DEVICE, + EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE, EntityType.ENTITY_VIEW, EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE, - EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL ); @Override diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java new file mode 100644 index 0000000000..8d6097b726 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/AiModelExportService.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.sync.ie.exporting.impl; + +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.util.Set; + +@Service +@TbCoreComponent +class AiModelExportService extends BaseEntityExportService> { + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.AI_MODEL); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java index 7d5f7ee57e..2f5355f637 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java @@ -38,6 +38,8 @@ public class DeviceExportService extends BaseEntityExportService ctx, Device device, DeviceExportData exportData) { device.setCustomerId(getExternalIdOrElseInternal(ctx, device.getCustomerId())); device.setDeviceProfileId(getExternalIdOrElseInternal(ctx, device.getDeviceProfileId())); + device.setFirmwareId(getExternalIdOrElseInternal(ctx, device.getFirmwareId())); + device.setSoftwareId(getExternalIdOrElseInternal(ctx, device.getSoftwareId())); if (ctx.getSettings().isExportCredentials()) { var credentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(ctx.getTenantId(), device.getId()); credentials.setId(null); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java index 6c212f0684..6a98bd2a7c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceProfileExportService.java @@ -34,6 +34,8 @@ public class DeviceProfileExportService extends BaseEntityExportService { + + @Override + protected void setRelatedEntities(EntitiesExportCtx ctx, OtaPackage otaPackage, OtaPackageExportData exportData) { + otaPackage.setDeviceProfileId(getExternalIdOrElseInternal(ctx, otaPackage.getDeviceProfileId())); + } + + @Override + protected OtaPackageExportData newExportData() { + return new OtaPackageExportData(); + } + + @Override + public Set getSupportedEntityTypes() { + return Set.of(EntityType.OTA_PACKAGE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java index 3ea50ea327..9850e2d1a1 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/csv/AbstractBulkImportService.java @@ -67,7 +67,6 @@ import org.thingsboard.server.service.security.permission.Resource; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.utils.CsvUtils; -import java.util.ArrayList; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -235,7 +234,7 @@ public abstract class AbstractBulkImportService kvsEntry, BulkImportColumnType kvType) { String scope = kvType.getKey(); - List attributes = new ArrayList<>(JsonConverter.convertToAttributes(kvsEntry.getValue())); + List attributes = JsonConverter.convertToAttributes(kvsEntry.getValue()); accessValidator.validateEntityAndCallback(user, Operation.WRITE_ATTRIBUTES, entity.getId(), (result, tenantId, entityId) -> { tsSubscriptionService.saveAttributes(AttributesSaveRequest.builder() diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java new file mode 100644 index 0000000000..34e70adb11 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/AiModelImportService.java @@ -0,0 +1,77 @@ +/** + * 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.sync.ie.importing.impl; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +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.common.data.sync.ie.EntityExportData; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.sync.vc.data.EntitiesImportCtx; + +@Service +@TbCoreComponent +@RequiredArgsConstructor +class AiModelImportService extends BaseEntityImportService> { + + private final AiModelService aiModelService; + + @Override + protected void setOwner( + TenantId tenantId, + AiModel model, + BaseEntityImportService>.IdProvider idProvider + ) { + model.setTenantId(tenantId); + } + + @Override + protected AiModel prepare( + EntitiesImportCtx ctx, + AiModel model, + AiModel oldModel, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider + ) { + return model; + } + + @Override + protected AiModel deepCopy(AiModel model) { + return new AiModel(model); + } + + @Override + protected AiModel saveOrUpdate( + EntitiesImportCtx ctx, + AiModel model, + EntityExportData exportData, + BaseEntityImportService>.IdProvider idProvider, + CompareResult compareResult + ) { + return aiModelService.save(model); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java index 57b4737be6..92fdcb09c4 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java @@ -71,7 +71,6 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.function.Function; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -148,6 +147,7 @@ public abstract class BaseEntityImportService importResult, D exportData, IdProvider idProvider) throws ThingsboardException { E savedEntity = importResult.getSavedEntity(); E oldEntity = importResult.getOldEntity(); @@ -405,7 +404,9 @@ public abstract class BaseEntityImportService ID getInternalId(ID externalId, boolean throwExceptionIfNotFound) { - if (externalId == null || externalId.isNullUid()) return null; + if (externalId == null || externalId.isNullUid()) { + return null; + } if (EntityType.TENANT.equals(externalId.getEntityType())) { return (ID) ctx.getTenantId(); @@ -432,7 +433,9 @@ public abstract class BaseEntityImportService getInternalIdByUuid(UUID externalUuid, boolean fetchAllUUIDs, Set hints) { - if (externalUuid.equals(EntityId.NULL_UUID)) return Optional.empty(); + if (externalUuid.equals(EntityId.NULL_UUID)) { + return Optional.empty(); + } for (EntityType entityType : EntityType.values()) { Optional externalId = buildEntityId(entityType, externalUuid); @@ -483,10 +486,6 @@ public abstract class BaseEntityImportService T getOldEntityField(O oldEntity, Function getter) { - return oldEntity == null ? null : getter.apply(oldEntity); - } - protected void replaceIdsRecursively(EntitiesImportCtx ctx, IdProvider idProvider, JsonNode json, Set skippedRootFields, Pattern includedFieldsPattern, LinkedHashSet hints) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java index 4ace9ff938..0cbb471b6f 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/DeviceImportService.java @@ -44,8 +44,8 @@ public class DeviceImportService extends BaseEntityImportService exportData, IdProvider idProvider, CompareResult compareResult) { + boolean toUpdate = ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds(); + if (toUpdate) { + deviceProfile.setFirmwareId(idProvider.getInternalId(deviceProfile.getFirmwareId())); + deviceProfile.setSoftwareId(idProvider.getInternalId(deviceProfile.getSoftwareId())); + } DeviceProfile saved = deviceProfileService.saveDeviceProfile(deviceProfile); - if (ctx.isFinalImportAttempt() || ctx.getCurrentImportResult().isUpdatedAllExternalIds()) { + if (toUpdate) { importCalculatedFields(ctx, saved, exportData, idProvider); } return saved; @@ -73,8 +78,6 @@ public class DeviceProfileImportService extends BaseEntityImportService { + + private final OtaPackageService otaPackageService; + + @Override + protected void setOwner(TenantId tenantId, OtaPackage otaPackage, IdProvider idProvider) { + otaPackage.setTenantId(tenantId); + } + + @Override + protected OtaPackage prepare(EntitiesImportCtx ctx, OtaPackage otaPackage, OtaPackage oldOtaPackage, OtaPackageExportData exportData, IdProvider idProvider) { + otaPackage.setDeviceProfileId(idProvider.getInternalId(otaPackage.getDeviceProfileId())); + return otaPackage; + } + + @Override + protected OtaPackage findExistingEntity(EntitiesImportCtx ctx, OtaPackage otaPackage, IdProvider idProvider) { + OtaPackage existingOtaPackage = super.findExistingEntity(ctx, otaPackage, idProvider); + if (existingOtaPackage == null && ctx.isFindExistingByName()) { + existingOtaPackage = otaPackageService.findOtaPackageByTenantIdAndTitleAndVersion(ctx.getTenantId(), otaPackage.getTitle(), otaPackage.getVersion()); + } + return existingOtaPackage; + } + + @Override + protected OtaPackage deepCopy(OtaPackage otaPackage) { + return new OtaPackage(otaPackage); + } + + @Override + protected OtaPackage saveOrUpdate(EntitiesImportCtx ctx, OtaPackage otaPackage, OtaPackageExportData exportData, IdProvider idProvider, CompareResult compareResult) { + if (otaPackage.hasUrl()) { + OtaPackageInfo info = new OtaPackageInfo(otaPackage); + return new OtaPackage(otaPackageService.saveOtaPackageInfo(info, info.hasUrl())); + } + return otaPackageService.saveOtaPackage(otaPackage); + } + + @Override + public EntityType getEntityType() { + return EntityType.OTA_PACKAGE; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java index 420e85dc6c..5fe891c847 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/DefaultEntitiesVersionControlService.java @@ -114,9 +114,8 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont private final TbTransactionalCache taskCache; private final VersionControlExecutor executor; - @SuppressWarnings("UnstableApiUsage") @Override - public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) throws Exception { + public ListenableFuture saveEntitiesVersion(User user, VersionCreateRequest request) { checkBranchName(request.getBranch()); var pendingCommit = gitServiceQueue.prepareCommit(user, request); DonAsynchron.withCallback(pendingCommit, commit -> { @@ -546,7 +545,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture autoCommit(User user, EntityId entityId) throws Exception { + public ListenableFuture autoCommit(User user, EntityId entityId) { var repositorySettings = repositorySettingsService.get(user.getTenantId()); if (repositorySettings == null || repositorySettings.isReadOnly()) { return Futures.immediateFuture(null); @@ -573,7 +572,7 @@ public class DefaultEntitiesVersionControlService implements EntitiesVersionCont } @Override - public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception { + public ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) { var repositorySettings = repositorySettingsService.get(user.getTenantId()); if (repositorySettings == null || repositorySettings.isReadOnly()) { return Futures.immediateFuture(null); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java index 3f22120d9c..e5ff89ebbd 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/vc/EntitiesVersionControlService.java @@ -69,9 +69,9 @@ public interface EntitiesVersionControlService { ListenableFuture checkVersionControlAccess(TenantId tenantId, RepositorySettings settings) throws Exception; - ListenableFuture autoCommit(User user, EntityId entityId) throws Exception; + ListenableFuture autoCommit(User user, EntityId entityId); - ListenableFuture autoCommit(User user, EntityType entityType, List entityIds) throws Exception; + ListenableFuture autoCommit(User user, EntityType entityType, List entityIds); ListenableFuture getEntityDataInfo(User user, EntityId entityId, String versionId); diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java index fd9d8b7141..8c4a375fae 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultAlarmSubscriptionService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.telemetry; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -171,6 +172,11 @@ public class DefaultAlarmSubscriptionService extends AbstractSubscriptionService return alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, type); } + @Override + public FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { + return alarmService.findLatestActiveByOriginatorAndTypeAsync(tenantId, originator, type); + } + @Override public Alarm findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type) { return alarmService.findLatestActiveByOriginatorAndType(tenantId, originator, type); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index c3d28f9fce..e9cc7da571 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -208,7 +208,7 @@ ui: # Help parameters help: # Base URL for UI help assets - base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.1}" + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.2}" # Database telemetry parameters database: @@ -323,7 +323,7 @@ cassandra: poll_ms: "${CASSANDRA_QUERY_POLL_MS:50}" # Interval in milliseconds for printing Cassandra query queue statistic rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}" - # set all data type values except target to null for the same ts on save + # When saving a value, set other data types to null (to avoid having multiple telemetry values with the same timestamp). set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:true}" # log one of cassandra queries with specified frequency (0 - logging is disabled) print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}" @@ -464,6 +464,14 @@ actors: allow_system_sms_service: "${ACTORS_RULE_ALLOW_SYSTEM_SMS_SERVICE:true}" # Specify thread pool size for external call service external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:50}" + # Configuration for the thread pool that executes HTTP calls to AI provider APIs + ai-requests-thread-pool: + # The base name for threads + pool-name: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_NAME:ai-requests}" + # The maximum number of concurrent HTTP requests + pool-size: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_SIZE:50}" + # The maximum time in seconds to wait for active tasks to complete during graceful shutdown + termination-timeout-seconds: "${ACTORS_RULE_AI_REQUESTS_THREAD_POOL_TERMINATION_TIMEOUT_SECONDS:60}" chain: # Errors for particular actors are persisted once per specified amount of milliseconds error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}" @@ -648,6 +656,9 @@ cache: trendzSettings: timeToLiveInMinutes: "${CACHE_SPECS_TRENDZ_SETTINGS_TTL:1440}" # Trendz settings cache TTL maxSize: "${CACHE_SPECS_TRENDZ_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + aiModel: + timeToLiveInMinutes: "${CACHE_SPECS_AI_MODEL_TTL:1440}" # AI model cache TTL + maxSize: "${CACHE_SPECS_AI_MODEL_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -847,22 +858,23 @@ audit-log: # Allowed values: OFF (disable), W (log write operations), RW (log read and write operations) logging-level: mask: - "device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation - "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. Allowed values: OFF (disable), W (log write operations), RW (log read and write operation + "device": "${AUDIT_LOG_MASK_DEVICE:W}" # Device logging levels. + "asset": "${AUDIT_LOG_MASK_ASSET:W}" # Asset logging levels. + "dashboard": "${AUDIT_LOG_MASK_DASHBOARD:W}" # Dashboard logging levels. + "widget_type": "${AUDIT_LOG_MASK_WIDGET_TYPE:W}" # Widget type logging levels. + "widgets_bundle": "${AUDIT_LOG_MASK_WIDGETS_BUNDLE:W}" # Widget bundles logging levels. + "customer": "${AUDIT_LOG_MASK_CUSTOMER:W}" # Customer logging levels. + "user": "${AUDIT_LOG_MASK_USER:W}" # User logging levels. + "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" # Rule chain logging levels. + "alarm": "${AUDIT_LOG_MASK_ALARM:W}" # Alarm logging levels. + "entity_view": "${AUDIT_LOG_MASK_ENTITY_VIEW:W}" # Entity view logging levels. + "device_profile": "${AUDIT_LOG_MASK_DEVICE_PROFILE:W}" # Device profile logging levels. + "asset_profile": "${AUDIT_LOG_MASK_ASSET_PROFILE:W}" # Asset profile logging levels. + "edge": "${AUDIT_LOG_MASK_EDGE:W}" # Edge logging levels. + "tb_resource": "${AUDIT_LOG_MASK_RESOURCE:W}" # TB resource logging levels. + "ota_package": "${AUDIT_LOG_MASK_OTA_PACKAGE:W}" # Ota package logging levels. + "calculated_field": "${AUDIT_LOG_MASK_CALCULATED_FIELD:W}" # Calculated field logging levels. + "ai_model": "${AUDIT_LOG_MASK_AI_MODEL:W}" # AI model logging levels. sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" @@ -1661,7 +1673,7 @@ queue: # Kafka properties for Notifications topics notifications: "${TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:1;min.insync.replicas:1}" # Kafka properties for JS Executor topics - js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1}" + js-executor: "${TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES:retention.ms:86400000;segment.bytes:52428800;retention.bytes:104857600;partitions:30;min.insync.replicas:1}" # Kafka properties for OTA updates topic ota-updates: "${TB_QUEUE_KAFKA_OTA_TOPIC_PROPERTIES:retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000;partitions:10;min.insync.replicas:1}" # Kafka properties for Version Control topic diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java new file mode 100644 index 0000000000..ae2972b0cc --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -0,0 +1,615 @@ +/** + * 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.controller; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.Test; +import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AiModelControllerTest extends AbstractControllerTest { + + /* --- Save API tests --- */ + + @Test + public void saveAiModel_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void saveAiModel_whenCreatingValidModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + AiModel model = constructValidOpenAiModel("Test model"); + + // WHEN + var savedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(savedModel.getId()).isNotNull(); + assertThat(savedModel.getUuidId()).isNotNull().isNotEqualTo(EntityId.NULL_UUID); + assertThat(savedModel.getId().getEntityType()).isEqualTo(EntityType.AI_MODEL); + assertThat(savedModel.getCreatedTime()).isPositive(); + assertThat(savedModel.getVersion()).isEqualTo(1); + assertThat(savedModel.getTenantId()).isEqualTo(tenantId); + assertThat(savedModel.getName()).isEqualTo("Test model"); + assertThat(savedModel.getConfiguration()).isEqualTo(model.getConfiguration()); + assertThat(savedModel.getExternalId()).isNull(); + } + + @Test + public void saveAiModel_whenUpdatingExistingModelAsTenantAdmin_shouldSucceed() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + var newModelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key-updated")) + .modelId("o4-mini") + .temperature(0.2) + .topP(0.4) + .frequencyPenalty(0.2) + .presencePenalty(0.5) + .maxOutputTokens(2000) + .timeoutSeconds(20) + .maxRetries(0) + .build(); + + model.setName("Test model updated"); + model.setConfiguration(newModelConfig); + + // WHEN + var updatedModel = doPost("/api/ai/model", model, AiModel.class); + + // THEN + + // verify returned object + assertThat(updatedModel.getId()).isEqualTo(model.getId()); + assertThat(updatedModel.getCreatedTime()).isEqualTo(model.getCreatedTime()); + assertThat(updatedModel.getVersion()).isEqualTo(2); + assertThat(updatedModel.getTenantId()).isEqualTo(tenantId); + assertThat(updatedModel.getName()).isEqualTo("Test model updated"); + assertThat(updatedModel.getConfiguration()).isEqualTo(newModelConfig); + assertThat(updatedModel.getExternalId()).isNull(); + } + + /* --- Get by ID API tests --- */ + + @Test + public void getAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModelById_whenGettingExistingModelAsTenantAdmin_shouldReturnModel() throws Exception { + // GIVEN + loginTenantAdmin(); + + var saved = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + // WHEN + AiModel actual = doGet("/api/ai/model/" + saved.getId(), AiModel.class); + + // THEN + assertThat(actual).isEqualTo(saved); + } + + @Test + public void getAiModelById_whenGettingNonexistentModelAsTenantAdmin_shouldReturnNotFound() throws Exception { + // GIVEN + loginTenantAdmin(); + + var nonexistentModelId = new AiModelId(Uuids.timeBased()); + + // WHEN + ResultActions result = doGet("/api/ai/model/" + nonexistentModelId); + + // THEN + result.andExpect(status().isNotFound()) + .andExpect(statusReason(is("AI model with id [" + nonexistentModelId + "] is not found"))); + } + + /* --- Get paged API tests --- */ + + @Test + public void getAiModels_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doGet("/api/ai/model?pageSize=10&page=0"); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModels_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doGet("/api/ai/model?pageSize=10&page=0"); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void getAiModels_testPagination() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class); + var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class); + var model3 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 3"), AiModel.class); + var model4 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 4"), AiModel.class); + var model5 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 5"), AiModel.class); + + // WHEN + PageData result = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(2, 1)); + + // THEN + assertThat(result.getData()).containsExactly(model3, model4); + assertThat(result.getTotalPages()).isEqualTo(3); + assertThat(result.getTotalElements()).isEqualTo(5); + assertThat(result.hasNext()).isTrue(); + } + + @Test + public void getAiModels_testSearchAndSortAppliedBeforePagination() throws Exception { + // GIVEN + loginTenantAdmin(); + + // Create 5 models: 3 with "Alpha" in name, 2 with "Beta" in name + var alpha1 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 1"), AiModel.class); + var beta1 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 1"), AiModel.class); + var alpha2 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 2"), AiModel.class); + var beta2 = doPost("/api/ai/model", constructValidOpenAiModel("Beta Model 2"), AiModel.class); + var alpha3 = doPost("/api/ai/model", constructValidOpenAiModel("Alpha Model 3"), AiModel.class); + + // WHEN + // Search for "Alpha", sort by name DESC, get the first page with size 2 + PageData result = doGetTypedWithPageLink("/api/ai/model?", + new TypeReference<>() {}, + new PageLink(2, 0, "Alpha", SortOrder.of("name", SortOrder.Direction.DESC))); + + // THEN + // Should find only 3 "Alpha" models, sort them DESC (3, 2, 1), then return first 2 + assertThat(result.getData()).containsExactly(alpha3, alpha2); + assertThat(result.getTotalPages()).isEqualTo(2); // One more "Alpha" model on the next page + assertThat(result.getTotalElements()).isEqualTo(3); // Only 3 models match "Alpha", not 5 + assertThat(result.hasNext()).isTrue(); // One more "Alpha" model on the next page + } + + @Test + public void getAiModels_testTextSearch() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("o3-pro") + .build()) + .build(), AiModel.class); + var model2 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-flash") + .build()) + .build(), AiModel.class); + var model3 = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 3") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-pro") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 10; + int page = 0; + SortOrder sortOrder = null; + + PageData result1 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "google ai", sortOrder)); + + PageData result2 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "pro", sortOrder)); + + PageData result3 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "test", sortOrder)); + + PageData result4 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "anthropic", sortOrder)); + + // THEN + + // should find google models + assertThat(result1.getData()).containsExactly(model2, model3); + assertThat(result1.getTotalPages()).isEqualTo(1); + assertThat(result1.getTotalElements()).isEqualTo(2); + assertThat(result1.hasNext()).isFalse(); + + // should find "o3-pro" and "gemini-2.5-pro" models + assertThat(result2.getData()).containsExactly(model1, model3); + assertThat(result2.getTotalPages()).isEqualTo(1); + assertThat(result2.getTotalElements()).isEqualTo(2); + assertThat(result2.hasNext()).isFalse(); + + // should find all models (all contain "Test" in their names) + assertThat(result3.getData()).containsExactly(model1, model2, model3); + assertThat(result3.getTotalPages()).isEqualTo(1); + assertThat(result3.getTotalElements()).isEqualTo(3); + assertThat(result3.hasNext()).isFalse(); + + // should find no models (nothing matches "anthropic") + assertThat(result4.getData()).isEmpty(); + assertThat(result4.getTotalPages()).isEqualTo(0); + assertThat(result4.getTotalElements()).isEqualTo(0); + assertThat(result4.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByCreatedTime() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model1 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 1"), AiModel.class); + var model2 = doPost("/api/ai/model", constructValidOpenAiModel("Test model 2"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(model1, model2); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(model2, model1); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByName() throws Exception { + // GIVEN + loginTenantAdmin(); + + var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class); + var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelB, modelA); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByProvider() throws Exception { + // GIVEN + loginTenantAdmin(); + + var anthropicModel = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-0") + .build()) + .build(), AiModel.class); + var geminiModel = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("gemini-2.5-pro") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(anthropicModel, geminiModel); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(geminiModel, anthropicModel); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByModelId() throws Exception { + // GIVEN + loginTenantAdmin(); + + var modelA = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 1") + .configuration(AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("model-a") + .build()) + .build(), AiModel.class); + + var modelB = doPost("/api/ai/model", AiModel.builder() + .tenantId(tenantId) + .name("Test model 2") + .configuration(GoogleAiGeminiChatModelConfig.builder() + .providerConfig(new GoogleAiGeminiProviderConfig("test-api-key")) + .modelId("model-b") + .build()) + .build(), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.DESC)) + ); + + // THEN + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelB, modelA); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + @Test + public void getAiModels_testSortingByIdTieBreaker() throws Exception { + // GIVEN + loginTenantAdmin(); + + // Both models are from OpenAI and sorting will be done on provider + var modelA = doPost("/api/ai/model", constructValidOpenAiModel("Test model A"), AiModel.class); + var modelB = doPost("/api/ai/model", constructValidOpenAiModel("Test model B"), AiModel.class); + + // WHEN + int pageSize = 2; + int page = 0; + String textSearch = null; + + PageData resultAsc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC)) + ); + PageData resultDesc = doGetTypedWithPageLink( + "/api/ai/model?", new TypeReference<>() {}, + new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.DESC)) + ); + + // THEN + + // in both cases result should be the same since in case of ties (both models have OpenAI as provider, sorting by ID ascending is used) + assertThat(resultAsc.getData()).containsExactly(modelA, modelB); + assertThat(resultAsc.getTotalPages()).isEqualTo(1); + assertThat(resultAsc.getTotalElements()).isEqualTo(2); + assertThat(resultAsc.hasNext()).isFalse(); + + assertThat(resultDesc.getData()).containsExactly(modelA, modelB); + assertThat(resultDesc.getTotalPages()).isEqualTo(1); + assertThat(resultDesc.getTotalElements()).isEqualTo(2); + assertThat(resultDesc.hasNext()).isFalse(); + } + + /* --- Delete API tests --- */ + + @Test + public void deleteAiModelById_whenUserIsSysAdmin_shouldReturnForbidden() throws Exception { + // GIVEN + loginSysAdmin(); + + // WHEN + ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void deleteAiModelById_whenUserIsCustomerUser_shouldReturnForbidden() throws Exception { + // GIVEN + loginCustomerUser(); + + // WHEN + ResultActions result = doDelete("/api/ai/model/" + Uuids.timeBased()); + + // THEN + result.andExpect(status().isForbidden()).andExpect(statusReason(equalTo(msgErrorPermission))); + } + + @Test + public void deleteAiModelById_whenDeletingExistingModelAsTenantAdmin_shouldSucceedAndReturnTrue() throws Exception { + // GIVEN + loginTenantAdmin(); + + var model = doPost("/api/ai/model", constructValidOpenAiModel("Test model"), AiModel.class); + + // WHEN + boolean deleted = doDelete("/api/ai/model/" + model.getId(), Boolean.class); + + // THEN + assertThat(deleted).isTrue(); + + // verify model cannot be found anymore + doGet("/api/ai/model/" + model.getId()) + .andExpect(status().isNotFound()) + .andExpect(statusReason(is("AI model with id [" + model.getId() + "] is not found"))); + } + + @Test + public void deleteAiModelById_whenDeletingNonexistentModelAsTenantAdmin_shouldSucceedAndReturnFalse() throws Exception { + // GIVEN + loginTenantAdmin(); + + var nonexistentModelId = new AiModelId(Uuids.timeBased()); + + // WHEN + boolean deleted = doDelete("/api/ai/model/" + nonexistentModelId, Boolean.class); + + // THEN + assertThat(deleted).isFalse(); + } + + private AiModel constructValidOpenAiModel(String name) { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("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.builder() + .tenantId(tenantId) + .name(name) + .configuration(modelConfig) + .build(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index 26c02e8704..ee18543796 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -36,10 +36,15 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; +import org.thingsboard.server.common.data.query.AlarmData; +import org.thingsboard.server.common.data.query.AlarmDataPageLink; +import org.thingsboard.server.common.data.query.AlarmDataQuery; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -230,6 +235,60 @@ public class EntityQueryControllerTest extends AbstractControllerTest { testCountAlarmsByQuery(alarms); } + @Test + public void testTenantCountAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + Device savedDevice = doPost("/api/device", device, Device.class); + devices.add(savedDevice); + Thread.sleep(1); + + Alarm alarm = new Alarm(); + alarm.setOriginator(savedDevice.getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + Asset savedAsset = doPost("/api/asset", asset, Asset.class); + assets.add(savedAsset); + Thread.sleep(1); + + Alarm alarm = new Alarm(); + alarm.setOriginator(savedAsset.getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmCountQuery assetAlarmQuery = new AlarmCountQuery(assetTypeFilter); + + Long assetAlamCount = doPostWithResponse("/api/alarmsQuery/count", assetAlarmQuery, Long.class); + Assert.assertEquals(assets.size(), assetAlamCount.longValue()); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmCountQuery filteredAssetAlarmQuery = new AlarmCountQuery(assetTypeFilter, keyFilters); + + Long filteredAssetAlamCount = doPostWithResponse("/api/alarmsQuery/count", filteredAssetAlarmQuery, Long.class); + Assert.assertEquals(1, filteredAssetAlamCount.longValue()); + } + @Test public void testCustomerCountAlarmsByQuery() throws Exception { loginTenantAdmin(); @@ -259,6 +318,213 @@ public class EntityQueryControllerTest extends AbstractControllerTest { testCountAlarmsByQuery(alarms); } + @Test + public void testCustomerCountAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + assets.add(doPost("/api/asset", asset, Asset.class)); + Thread.sleep(1); + } + + loginCustomerUser(); + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(devices.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + for (int i = 0; i < assets.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(assets.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmCountQuery assetAlarmQuery = new AlarmCountQuery(assetTypeFilter); + + Long assetAlamCount = doPostWithResponse("/api/alarmsQuery/count", assetAlarmQuery, Long.class); + Assert.assertEquals(10, assetAlamCount.longValue()); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmCountQuery filteredAssetAlarmQuery = new AlarmCountQuery(assetTypeFilter, keyFilters); + + Long filteredAssetAlamCount = doPostWithResponse("/api/alarmsQuery/count", filteredAssetAlarmQuery, Long.class); + Assert.assertEquals(1, filteredAssetAlamCount.longValue()); + } + + @Test + public void testFindTenantAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + assets.add(doPost("/api/asset", asset, Asset.class)); + Thread.sleep(1); + } + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setOriginator(devices.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + List assetAlarmTypes = new ArrayList<>(); + for (int i = 0; i < assets.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setOriginator(assets.get(i).getId()); + String type = "asset alarm" + i; + alarm.setType(type); + assetAlarmTypes.add(type); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "assignee"))); + + List alarmFields = new ArrayList<>(); + alarmFields.add(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, alarmFields); + + PageData alarmPageData = doPostWithTypedResponse("/api/alarmsQuery/find", assetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(10, alarmPageData.getTotalElements()); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, alarmFields); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); + } + + @Test + public void testFindCustomerAlarmsWithEntityFilter() throws Exception { + loginTenantAdmin(); + List devices = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Device device = new Device(); + device.setCustomerId(customerId); + device.setName("Device" + i); + device.setType("default"); + device.setLabel("testLabel" + (int) (Math.random() * 1000)); + devices.add(doPost("/api/device", device, Device.class)); + Thread.sleep(1); + } + + List assets = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Asset asset = new Asset(); + asset.setCustomerId(customerId); + asset.setName("Asset" + i); + asset.setType("default"); + asset.setLabel("testLabel" + (int) (Math.random() * 1000)); + assets.add(doPost("/api/asset", asset, Asset.class)); + Thread.sleep(1); + } + + loginCustomerUser(); + + for (int i = 0; i < devices.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(devices.get(i).getId()); + alarm.setType("alarm" + i); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + List assetAlarmTypes = new ArrayList<>(); + for (int i = 0; i < assets.size(); i++) { + Alarm alarm = new Alarm(); + alarm.setCustomerId(customerId); + alarm.setOriginator(assets.get(i).getId()); + String type = "asset alarm" + i; + alarm.setType(type); + assetAlarmTypes.add(type); + alarm.setSeverity(AlarmSeverity.WARNING); + doPost("/api/alarm", alarm, Alarm.class); + Thread.sleep(1); + } + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "assignee"))); + + EntityTypeFilter assetTypeFilter = new EntityTypeFilter(); + assetTypeFilter.setEntityType(EntityType.ASSET); + AlarmDataQuery assetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, null, Collections.emptyList()); + + PageData alarmPageData = doPostWithTypedResponse("/api/alarmsQuery/find", assetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(10, alarmPageData.getTotalElements()); + List retrievedAlarmTypes = alarmPageData.getData().stream().map(Alarm::getType).toList(); + assertThat(retrievedAlarmTypes).containsExactlyInAnyOrderElementsOf(assetAlarmTypes); + + KeyFilter nameFilter = buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.STARTS_WITH, "Asset1"); + List keyFilters = Collections.singletonList(nameFilter); + AlarmDataQuery filteredAssetAlarmQuery = new AlarmDataQuery(assetTypeFilter, pageLink, null, null, keyFilters, Collections.emptyList()); + PageData filteredAssetAlamData = doPostWithTypedResponse("/api/alarmsQuery/find", filteredAssetAlarmQuery, new TypeReference<>() { + }); + Assert.assertEquals(1, filteredAssetAlamData.getTotalElements()); + } + private void testCountAlarmsByQuery(List alarms) throws Exception { AlarmCountQuery countQuery = new AlarmCountQuery(); @@ -442,7 +708,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { } RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(mainDevice.getId()); + filter.setRootEntity(AliasEntityId.fromEntityId(mainDevice.getId())); filter.setDirection(EntitySearchDirection.FROM); filter.setNegate(true); filter.setFilters(List.of(new RelationEntityTypeFilter("CONTAINS", List.of(EntityType.DEVICE), false))); @@ -913,4 +1179,14 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return numericFilter; } + private KeyFilter buildStringKeyFilter(EntityKeyType entityKeyType, String name, StringFilterPredicate.StringOperation operation, String value) { + KeyFilter nameFilter = new KeyFilter(); + nameFilter.setKey(new EntityKey(entityKeyType, name)); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setOperation(operation); + predicate.setValue(FilterPredicateValue.fromString(value)); + nameFilter.setPredicate(predicate); + return nameFilter; + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java index a1570c4903..ad3c6d5312 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -19,10 +19,12 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Assert; import org.junit.Test; import org.springframework.test.context.TestPropertySource; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.security.DeviceCredentials; @@ -32,6 +34,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.query.EntityKeyType.TIME_SERIES; @@ -115,7 +118,7 @@ public class TelemetryControllerTest extends AbstractControllerTest { Device device = createDevice(); SingleEntityFilter filter = new SingleEntityFilter(); - filter.setSingleEntity(device.getId()); + filter.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); getWsClient().subscribeLatestUpdate(List.of(new EntityKey(TIME_SERIES, "data")), filter); @@ -159,7 +162,7 @@ public class TelemetryControllerTest extends AbstractControllerTest { Device device = createDevice(); SingleEntityFilter filter = new SingleEntityFilter(); - filter.setSingleEntity(device.getId()); + filter.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); getWsClient().subscribeLatestUpdate(List.of(new EntityKey(TIME_SERIES, "data")), filter); @@ -207,6 +210,15 @@ public class TelemetryControllerTest extends AbstractControllerTest { doPostAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/smth", invalidRequestBody, String.class, status().isBadRequest()); } + @Test + public void testBadRequestReturnedWhenMethodArgumentTypeMismatch() throws Exception { + loginTenantAdmin(); + String content = "{\"key\": \"value\"}"; + doPost("/api/plugins/telemetry/DEVICE/20b559f5-849f-4361-b4f6-b6d0b76687e9/INVALID_SCOPE", content, (String) null) + .andExpect(status().isBadRequest()) + .andExpect(result -> assertThat(result.getResolvedException()).isInstanceOf(MethodArgumentTypeMismatchException.class)); + } + @Test public void testEmptyKeyIsProhibited() throws Exception { loginTenantAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java index 3842cf917b..bebdd5ecb9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; @@ -332,7 +333,7 @@ public class WebsocketApiTest extends AbstractControllerTest { loginTenantAdmin(); SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(tenantId); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); AlarmCountQuery alarmCountQuery = new AlarmCountQuery(singleEntityFilter); AlarmCountCmd cmd1 = new AlarmCountCmd(1, alarmCountQuery); @@ -356,7 +357,7 @@ public class WebsocketApiTest extends AbstractControllerTest { Assert.assertEquals(1, update.getCount()); // set wrong entity id in filter, check count = 0 - singleEntityFilter.setSingleEntity(tenantAdminUserId); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantAdminUserId)); AlarmCountCmd cmd3 = new AlarmCountCmd(2, alarmCountQuery); getWsClient().send(cmd3); @@ -865,7 +866,7 @@ public class WebsocketApiTest extends AbstractControllerTest { public void testAttributesSubscription_sysAdmin() throws Exception { loginSysAdmin(); SingleEntityFilter entityFilter = new SingleEntityFilter(); - entityFilter.setSingleEntity(tenantId); + entityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); assertThatNoException().as("subscribeForAttributes").isThrownBy(() -> { JsonNode update = getWsClient().subscribeForAttributes(tenantId, TbAttributeSubscriptionScope.SERVER_SCOPE.name(), List.of("attr")); diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index d8bcd42bc8..14f90822bf 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -54,6 +54,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; import org.thingsboard.server.common.data.query.AssetTypeFilter; @@ -217,7 +218,7 @@ public class EntityServiceTest extends AbstractControllerTest { createTestHierarchy(tenantId, assets, devices, new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); EntityCountQuery countQuery = new EntityCountQuery(filter); @@ -226,13 +227,13 @@ public class EntityServiceTest extends AbstractControllerTest { filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE)))); countByQueryAndCheck(countQuery, 25); - filter.setRootEntity(devices.get(0).getId()); + filter.setRootEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); filter.setDirection(EntitySearchDirection.TO); filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Manages", Collections.singletonList(EntityType.TENANT)))); countByQueryAndCheck(countQuery, 1); DeviceSearchQueryFilter filter2 = new DeviceSearchQueryFilter(); - filter2.setRootEntity(tenantId); + filter2.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter2.setDirection(EntitySearchDirection.FROM); filter2.setRelationType("Contains"); @@ -242,12 +243,12 @@ public class EntityServiceTest extends AbstractControllerTest { filter2.setDeviceTypes(Arrays.asList("default0", "default1")); countByQueryAndCheck(countQuery, 10); - filter2.setRootEntity(devices.get(0).getId()); + filter2.setRootEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); filter2.setDirection(EntitySearchDirection.TO); countByQueryAndCheck(countQuery, 0); AssetSearchQueryFilter filter3 = new AssetSearchQueryFilter(); - filter3.setRootEntity(tenantId); + filter3.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter3.setDirection(EntitySearchDirection.FROM); filter3.setRelationType("Manages"); @@ -257,7 +258,7 @@ public class EntityServiceTest extends AbstractControllerTest { filter3.setAssetTypes(Arrays.asList("type0", "type1")); countByQueryAndCheck(countQuery, 2); - filter3.setRootEntity(devices.get(0).getId()); + filter3.setRootEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); filter3.setDirection(EntitySearchDirection.TO); countByQueryAndCheck(countQuery, 0); } @@ -268,7 +269,7 @@ public class EntityServiceTest extends AbstractControllerTest { createTestUserRelations(tenantId, users); RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); EntityDataPageLink pageLink = new EntityDataPageLink(10, 0, null, null); @@ -352,7 +353,7 @@ public class EntityServiceTest extends AbstractControllerTest { } EdgeSearchQueryFilter filter = new EdgeSearchQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setRelationType("Manages"); @@ -404,7 +405,7 @@ public class EntityServiceTest extends AbstractControllerTest { Futures.allAsList(attributeFutures).get(); RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setFilters(Collections.singletonList(new RelationEntityTypeFilter("Contains", Collections.singletonList(EntityType.DEVICE)))); filter.setMaxLevel(maxLevel); @@ -554,7 +555,7 @@ public class EntityServiceTest extends AbstractControllerTest { Futures.allAsList(attributeFutures).get(); DeviceSearchQueryFilter filter = new DeviceSearchQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setRelationType("Contains"); filter.setMaxLevel(2); @@ -608,7 +609,7 @@ public class EntityServiceTest extends AbstractControllerTest { Futures.allAsList(attributeFutures).get(); AssetSearchQueryFilter filter = new AssetSearchQueryFilter(); - filter.setRootEntity(tenantId); + filter.setRootEntity(AliasEntityId.fromEntityId(tenantId)); filter.setDirection(EntitySearchDirection.FROM); filter.setRelationType("Manages"); @@ -1101,7 +1102,7 @@ public class EntityServiceTest extends AbstractControllerTest { } SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(devices.get(0).getId()); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(devices.get(0).getId())); List entityFields = List.of( new EntityKey(EntityKeyType.ENTITY_FIELD, "name") @@ -1120,7 +1121,7 @@ public class EntityServiceTest extends AbstractControllerTest { @Test public void testFindCustomerBySingleEntityFilter() { SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(customerId); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(customerId)); List entityFields = List.of( new EntityKey(EntityKeyType.ENTITY_FIELD, "name") ); @@ -1192,7 +1193,7 @@ public class EntityServiceTest extends AbstractControllerTest { List keyFiltersEqualString = createStringKeyFilters("name", EntityKeyType.ENTITY_FIELD, StringOperation.STARTS_WITH, "Test device "); for (Asset asset : assets) { - filter.setRootEntity(asset.getId()); + filter.setRootEntity(AliasEntityId.fromEntityId(asset.getId())); EntityDataQuery query = new EntityDataQuery(filter, pageLink, Collections.emptyList(), Collections.emptyList(), keyFiltersEqualString); findByQueryAndCheck(customer.getId(), query, relationsCnt); @@ -1384,7 +1385,7 @@ public class EntityServiceTest extends AbstractControllerTest { } SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); - singleEntityFilter.setSingleEntity(customerDevices.get(0).getId()); + singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(customerDevices.get(0).getId())); List entityFields = List.of( new EntityKey(EntityKeyType.ENTITY_FIELD, "name") ); @@ -1403,7 +1404,7 @@ public class EntityServiceTest extends AbstractControllerTest { // try to find tenant device by customer user SingleEntityFilter tenantDeviceFilter = new SingleEntityFilter(); - tenantDeviceFilter.setSingleEntity(tenantDevices.get(0).getId()); + tenantDeviceFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantDevices.get(0).getId())); EntityDataQuery customerQuery2 = new EntityDataQuery(tenantDeviceFilter, pageLink, entityFields, null, null); findByQueryAndCheck(customerId, customerQuery2, 0); } diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java new file mode 100644 index 0000000000..2321446b44 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/ai/DefaultTbAiModelServiceTest.java @@ -0,0 +1,268 @@ +/** + * 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.entitiy.ai; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.service.entitiy.TbLogEntityActionService; +import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class DefaultTbAiModelServiceTest { + + @Mock + EntitiesVersionControlService vcServiceMock; + + @Mock + AiModelService aiModelServiceMock; + + @Mock + TbLogEntityActionService logEntityActionServiceMock; + + @Spy + @InjectMocks + DefaultTbAiModelService service; + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + User user; + + @BeforeEach + void setUp() { + user = new User(); + user.setTenantId(tenantId); + + service = new DefaultTbAiModelService(aiModelServiceMock); + ReflectionTestUtils.setField(service, "vcService", vcServiceMock); + ReflectionTestUtils.setField(service, "logEntityActionService", logEntityActionServiceMock); + } + + @Test + void save_whenCreatingNewModel_shouldAutoCommitAndLogAddedActionAndUseTenantIdFromUser() { + // GIVEN + var modelToSave = AiModel.builder() + .name("Model to save") + .configuration(constructValidOpenAiModelConfig()) + .build(); + + var savedModel = new AiModel(modelToSave); + savedModel.setId(new AiModelId(UUID.randomUUID())); + savedModel.setTenantId(user.getTenantId()); + savedModel.setVersion(1L); + savedModel.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.save(modelToSave)).willReturn(savedModel); + + // WHEN + AiModel result = service.save(modelToSave, user); + + // THEN + assertThat(result).isEqualTo(savedModel); + + then(aiModelServiceMock).should().save(modelToSave); + then(vcServiceMock).should().autoCommit(user, savedModel.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, savedModel.getId(), savedModel, ActionType.ADDED, user); + } + + @Test + void save_whenUpdatingExistingModel_shouldAutoCommitAndLogUpdatedAction() { + // GIVEN + var modelToUpdate = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to update") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToUpdate.setId(new AiModelId(UUID.randomUUID())); + modelToUpdate.setCreatedTime(System.currentTimeMillis()); + + var updatedModel = new AiModel(modelToUpdate); + updatedModel.setVersion(2L); + updatedModel.setName("Updated model"); + + given(aiModelServiceMock.save(modelToUpdate)).willReturn(updatedModel); + + // WHEN + AiModel result = service.save(modelToUpdate, user); + + // THEN + assertThat(result).isEqualTo(updatedModel); + + then(aiModelServiceMock).should().save(modelToUpdate); + then(vcServiceMock).should().autoCommit(user, updatedModel.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, updatedModel.getId(), updatedModel, ActionType.UPDATED, user); + } + + @Test + void save_whenCreatingNewModelThrowsException_shouldUseEmptyIdAndLogError() { + // GIVEN + var modelToSave = AiModel.builder() + .tenantId(tenantId) + .name("Model to save") + .configuration(constructValidOpenAiModelConfig()) + .build(); + + var exception = new RuntimeException("Failed to save"); + + given(aiModelServiceMock.save(modelToSave)).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.save(modelToSave, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to save"); + + then(aiModelServiceMock).should().save(modelToSave); + then(vcServiceMock).should(never()).autoCommit(any(), any()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, new AiModelId(EntityId.NULL_UUID), modelToSave, ActionType.ADDED, user, exception); + } + + @Test + void save_whenUpdatingExistingModelThrowsException_shouldUseExistingModelIdAndLogError() { + // GIVEN + var modelToUpdate = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to update") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToUpdate.setId(new AiModelId(UUID.randomUUID())); + modelToUpdate.setCreatedTime(System.currentTimeMillis()); + + var exception = new RuntimeException("Failed to save"); + + given(aiModelServiceMock.save(modelToUpdate)).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.save(modelToUpdate, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to save"); + + then(aiModelServiceMock).should().save(modelToUpdate); + then(vcServiceMock).should(never()).autoCommit(any(), any()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToUpdate.getId(), modelToUpdate, ActionType.UPDATED, user, exception); + } + + @Test + void delete_whenDeleteSuccessful_shouldLogDeletedAction() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(true); + + // WHEN + boolean result = service.delete(modelToDelete, user); + + // THEN + assertThat(result).isTrue(); + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString()); + } + + @Test + void delete_whenDeleteReturnsFalse_shouldNotLogAction() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willReturn(false); + + // WHEN + boolean result = service.delete(modelToDelete, user); + + // THEN + assertThat(result).isFalse(); + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should(never()).logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, modelToDelete.getId().toString()); + } + + @Test + void delete_whenDeleteThrowsException_shouldLogError() { + // GIVEN + var modelToDelete = AiModel.builder() + .tenantId(tenantId) + .version(1L) + .name("Model to delete") + .configuration(constructValidOpenAiModelConfig()) + .build(); + modelToDelete.setId(new AiModelId(UUID.randomUUID())); + modelToDelete.setCreatedTime(System.currentTimeMillis()); + + var exception = new RuntimeException("Failed to delete"); + + given(aiModelServiceMock.deleteByTenantIdAndId(tenantId, modelToDelete.getId())).willThrow(exception); + + // WHEN-THEN + assertThatThrownBy(() -> service.delete(modelToDelete, user)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to delete"); + + then(aiModelServiceMock).should().deleteByTenantIdAndId(tenantId, modelToDelete.getId()); + then(logEntityActionServiceMock).should().logEntityAction(tenantId, modelToDelete.getId(), modelToDelete, ActionType.DELETED, user, exception, modelToDelete.getId().toString()); + } + + private static AiModelConfig constructValidOpenAiModelConfig() { + return OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("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(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java index d701ce6113..a276a9c5ff 100644 --- a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java @@ -31,6 +31,8 @@ import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; import org.thingsboard.rule.engine.metadata.TbGetAttributesNodeConfiguration; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.StringUtils; @@ -77,6 +79,8 @@ import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.alarm.AlarmDao; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.entity.EntityServiceRegistry; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.relation.RelationService; @@ -145,6 +149,10 @@ public class HousekeeperServiceTest extends AbstractControllerTest { private ApiUsageStateDao apiUsageStateDao; @Autowired private EntityServiceRegistry entityServiceRegistry; + @Autowired + private CustomerService customerService; + @Autowired + private DashboardService dashboardService; @SpyBean private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor; @@ -238,11 +246,67 @@ public class HousekeeperServiceTest extends AbstractControllerTest { doDelete("/api/device/" + device.getId()).andExpect(status().isOk()); - await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { verifyNoAlarms(device.getId()); }); } + @Test + public void whenAssetIsDeleted_thenDeleteAllAlarms() throws Exception { + Asset asset = createAsset(); + for (int i = 1; i <= 1000; i++) { + createAlarm(asset.getId()); + } + + doDelete("/api/asset/" + asset.getId()).andExpect(status().isOk()); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + verifyNoAlarms(asset.getId()); + }); + } + + @Test + public void whenDashboardIsDeleted_thenDeleteAllAlarms() throws Exception { + Dashboard dashboard = createDashboard(); + for (int i = 1; i <= 1000; i++) { + createAlarm(dashboard.getId()); + } + + doDelete("/api/dashboard/" + dashboard.getId()).andExpect(status().isOk()); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + verifyNoAlarms(dashboard.getId()); + }); + } + + @Test + public void whenCustomerIsDeleted_thenDeleteAllAlarms() throws Exception { + Customer customer = createCustomer(); + for (int i = 1; i <= 1000; i++) { + createAlarm(customer.getId()); + } + + doDelete("/api/customer/" + customer.getId()).andExpect(status().isOk()); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + verifyNoAlarms(customer.getId()); + }); + } + + @Test + public void whenUserIsDeleted_thenDeleteAllAlarms() throws Exception { + UserId userId = customerUserId; + for (int i = 1; i <= 1000; i++) { + createAlarm(userId); + } + + doDelete("/api/user/" + userId).andExpect(status().isOk()); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + verifyNoAlarms(userId); + }); + } + @Test public void whenTenantIsDeleted_thenDeleteAllEntitiesAndCleanUpRelatedData() throws Exception { loginDifferentTenant(); @@ -335,7 +399,7 @@ public class HousekeeperServiceTest extends AbstractControllerTest { doDelete("/api/device/" + device.getId()).andExpect(status().isOk()); int attempts = 2; - await().atMost(30, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).untilAsserted(() -> { for (int i = 0; i <= attempts; i++) { int attempt = i; verify(housekeeperReprocessingService).submitForReprocessing(argThat(getTaskMatcher(device.getId(), HousekeeperTaskType.DELETE_TS_HISTORY, @@ -345,7 +409,7 @@ public class HousekeeperServiceTest extends AbstractControllerTest { assertThat(getTimeseriesHistory(device.getId())).isNotEmpty(); doCallRealMethod().when(tsHistoryDeletionTaskProcessor).process(any()); - await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(getTimeseriesHistory(device.getId())).isEmpty(); }); } @@ -379,7 +443,7 @@ public class HousekeeperServiceTest extends AbstractControllerTest { doDelete("/api/device/" + device.getId()).andExpect(status().isOk()); int attempts = 2; - await().atMost(30, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).pollInterval(1, TimeUnit.SECONDS).untilAsserted(() -> { for (int i = 0; i <= attempts; i++) { int attempt = i; verify(housekeeperReprocessingService).submitForReprocessing(argThat(getTaskMatcher(device.getId(), HousekeeperTaskType.DELETE_TS_HISTORY, @@ -393,7 +457,7 @@ public class HousekeeperServiceTest extends AbstractControllerTest { doCallRealMethod().when(tsHistoryDeletionTaskProcessor).process(any()); someExecutor.shutdown(); - await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(getTimeseriesHistory(device.getId())).isEmpty(); }); } @@ -409,7 +473,7 @@ public class HousekeeperServiceTest extends AbstractControllerTest { doDelete("/api/device/" + device.getId()).andExpect(status().isOk()); int maxAttempts = 5; - await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { for (int i = 1; i <= maxAttempts; i++) { verifyTaskProcessing(device.getId(), HousekeeperTaskType.DELETE_TS_HISTORY, i); } @@ -479,7 +543,6 @@ public class HousekeeperServiceTest extends AbstractControllerTest { eventService.saveAsync(event); await().atMost(10, TimeUnit.SECONDS) .until(() -> !getEvents(entityId).isEmpty()); - } private void createRelation(DeviceId to, AssetId from) { @@ -502,14 +565,14 @@ public class HousekeeperServiceTest extends AbstractControllerTest { assertThat(alarmService.findAlarmIdsByOriginatorId(tenantId, deviceId, 0, null, 10)).isNotEmpty(); } - private void createAlarm(DeviceId deviceId) { - Alarm alarm = doPost("/api/alarm", Alarm.builder() + private void createAlarm(EntityId entityId) { + doPost("/api/alarm", Alarm.builder() .tenantId(tenantId) - .originator(deviceId) + .originator(entityId) .severity(AlarmSeverity.CRITICAL) - .type("test alarm for " + deviceId + " " + RandomStringUtils.randomAlphabetic(10)) + .type("test alarm for " + entityId + " " + RandomStringUtils.randomAlphabetic(10)) .build(), Alarm.class); - assertThat(alarmService.findAlarmIdsByOriginatorId(tenantId, deviceId, 0, null, 10)).isNotEmpty(); + assertThat(alarmService.findAlarmIdsByOriginatorId(tenantId, entityId, 0, null, 10)).isNotEmpty(); } private TsKvEntry getLatestTelemetry(EntityId entityId) throws Exception { @@ -534,6 +597,20 @@ public class HousekeeperServiceTest extends AbstractControllerTest { return doPost("/api/asset", asset, Asset.class); } + private Customer createCustomer() { + Customer customer = new Customer(); + customer.setTenantId(tenantId); + customer.setTitle(StringUtils.randomAlphabetic(10)); + return customerService.saveCustomer(customer); + } + + private Dashboard createDashboard() { + Dashboard dashboard = new Dashboard(); + dashboard.setTenantId(tenantId); + dashboard.setTitle(StringUtils.randomAlphabetic(10)); + return dashboardService.saveDashboard(dashboard); + } + private RuleChainMetaData createRuleChain() { RuleChain ruleChain = new RuleChain(); ruleChain.setTenantId(tenantId); diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java index c283a791fd..a7affe6dff 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java @@ -26,8 +26,10 @@ import java.util.Base64; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.concurrent.ExecutionException; import java.util.concurrent.atomic.AtomicReference; @@ -750,6 +752,284 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected, actual); } + + // Sets + @Test + public void setsCreateNewSetFromMap_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["B", "A", "C", "A"]} + """; + decoderStr = """ + var originalMap = {}; + var set1 = originalMap.entrySet(); // create new Set from map, Empty + var set2 = set1.clone(); // clone new Set, Empty + var result1 = set1.addAll(msg.list); // addAll list, no sort, size = 3 ("A" - duplicate) + return {set1: set1, + set2: set2, + result1: result1 + } + """; + Set expectedSet1 = new LinkedHashSet(List.of("B", "A", "C", "A")); + Set expectedSet2 = new LinkedHashSet(); + Map expected = new LinkedHashMap<>(); + expected.put("set1", expectedSet1); + expected.put("set2", expectedSet2); + expected.put("result1", true); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void setsCreateNewSetFromCreateSetTbMethod_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["B", "A", "C", "A"]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new Set from toSet() with list, no sort, size = 3 ("A" - duplicate) + var set2 = newSet(); // create new Set from newSet(), Empty + return {set1: set1, + set2: set2 + } + """; + Set expectedSet1 = new LinkedHashSet(List.of("B", "A", "C", "A")); + Set expectedSet2 = new LinkedHashSet(); + Map expected = new LinkedHashMap<>(); + expected.put("set1", expectedSet1); + expected.put("set2", expectedSet2); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void setsForeachForLoop_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["A", "B", "C"]} + """; + decoderStr = """ + var set2 = toSet(msg.list); // create new from list, size = 3 + var set2_0 = set2.toArray()[0]; // return "A", value with index = 0 from Set + var set2Size = set2.size(); // return size = 3 + var smthForeach = ""; + foreach (item : set2) { // foreach for Set + smthForeach += item; // return "ABC" + } + var smthForLoop= ""; + var set2Array = set2.toArray(); // for loop for Set (Set to array)) + for (var i =0; i < set2.size; i++) { + smthForLoop += set2Array[i]; // return "ABC" + } + return { + set2: set2, + set2_0: set2_0, + set2Size: set2Size, + smthForeach: smthForeach, + smthForLoop: smthForLoop + } + """; + Set expectedSet2 = new LinkedHashSet(List.of("A", "B", "C")); + Map expected = new LinkedHashMap<>(); + expected.put("set2", expectedSet2); + expected.put("set2_0", expectedSet2.toArray()[0]); + expected.put("set2Size", expectedSet2.size()); + AtomicReference smth = new AtomicReference<>(""); + expectedSet2.forEach(s -> smth.updateAndGet(v -> v + s)); + expected.put("smthForeach", smth.get()); + expected.put("smthForLoop", smth.get()); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + /** + * add + * delete/remove + * setCreate, setCreatList + */ + @Test + public void setsAddRemove_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["B", "C", "A", "B", "C", "hello", 34567]} + """; + decoderStr = """ + // add + var setAdd = toSet(["thigsboard", 4, 67]); // create new, size = 3 + var setAdd1_value = setAdd.clone(); // clone setAdd, size = 3 + var setAdd2_result = setAdd.add(35); // add value = 35, result = true + var setAdd2_value = setAdd.clone(); // clone setAdd (fixing the result add = 35), size = 4 + var setAddList1 = toSet(msg.list); // create new from list without duplicate value ("B" and "C" - only one), size = 5 + var setAdd3_result = setAdd.addAll(setAddList1); // add all without duplicate values, result = true + var setAdd3_value = setAdd.clone(); // clone setAdd (with addAll), size = 9 + var setAdd4_result = setAdd.add(35); // add duplicate value = 35, result = false + var setAdd4_value = setAdd.clone(); // clone setAdd (after add duplicate value = 35), size = 9 + var setAddList2 = toSet(msg.list); // create new from list without duplicate value ("B" and "C" - only one), start: size = 5, finish: size = 7 + var setAdd5_result1 = setAddList2.add(72); // add is not duplicate value = 72, result = true + var setAdd5_result2 = setAddList2.add(72); // add duplicate value = 72, result = false + var setAdd5_result3 = setAddList2.add("hello25"); // add is not duplicate value = "hello25", result = true + var setAdd5_value = setAddList2.clone(); // clone setAddList2, size = 7 + var setAdd6_result = setAdd.addAll(setAddList2); // add all with duplicate values, result = true + var setAdd6_value = setAdd.clone(); // clone setAdd (after addAll setAddList2), before size = 9, after size = 11, added only is not duplicate values {"hello25", 72} + + // remove + var setAdd7_value = setAdd6_value.clone(); // clone setAdd6_value, before size = 11, after remove value = 4 size = 10 + var setAdd7_result = setAdd7_value.remove(4); // remove value = 4, result = true + var setAdd8_value = setAdd7_value.clone(); // clone setAdd7_value, before size = 10, after clear size = 0 + setAdd8_value.clear(); // setAdd8_value clear, result size = 0 + return { + "setAdd1_value": setAdd1_value, + "setAdd2_result": setAdd2_result, + "setAdd2_value": setAdd2_value, + "setAddList1": setAddList1, + "setAdd3_result": setAdd3_result, + "setAdd3_value": setAdd3_value, + "setAdd4_result": setAdd4_result, + "setAdd4_value": setAdd4_value, + "setAdd5_result1": setAdd5_result1, + "setAdd5_result2": setAdd5_result2, + "setAdd5_result3": setAdd5_result3, + "setAddList2": setAddList2, + "setAdd5_value": setAdd5_value, + "setAdd6_result": setAdd6_result, + "setAdd6_value": setAdd6_value, + "setAdd7_result": setAdd7_result, + "setAdd7_value": setAdd7_value, + "setAdd8_value": setAdd8_value + }; + """; + ArrayList list = new ArrayList<>(List.of("B", "C", "A", "B", "C", "hello", 34567)); + ArrayList listAdd = new ArrayList<>(List.of("thigsboard", 4, 67)); + Set setAdd = new LinkedHashSet<>(listAdd); + Set setAdd1_value = new LinkedHashSet<>(setAdd); + boolean setAdd2_result = setAdd.add(35); + Set setAdd2_value = new LinkedHashSet<>(setAdd); + Set setAddList1 = new LinkedHashSet<>(list); + boolean setAdd3_result = setAdd.addAll(setAddList1); + Set setAdd3_value = new LinkedHashSet<>(setAdd); + boolean setAdd4_result = setAdd.add(35); + Set setAdd4_value = new LinkedHashSet<>(setAdd); + Set setAddList2 = new LinkedHashSet<>(list); + boolean setAdd5_result1 = setAddList2.add(72); + boolean setAdd5_result2 = setAddList2.add(72); + boolean setAdd5_result3 = setAddList2.add("hello25"); + Set setAdd5_value = new LinkedHashSet<>(setAddList2); + boolean setAdd6_result = setAdd.addAll(setAddList2); + Set setAdd6_value = new LinkedHashSet<>(setAdd); + // remove + Set setAdd7_value = new LinkedHashSet<>(setAdd6_value); + boolean setAdd7_result = setAdd7_value.remove(4); + Set setAdd8_value = new LinkedHashSet<>(setAdd7_value); + setAdd8_value.clear(); + + LinkedHashMap expected = new LinkedHashMap<>(); + expected.put("setAdd1_value", setAdd1_value); + expected.put("setAdd2_result", setAdd2_result); + expected.put("setAdd2_value", setAdd2_value); + expected.put("setAddList1", setAddList1); + expected.put("setAdd3_result", setAdd3_result); + expected.put("setAdd3_value", setAdd3_value); + expected.put("setAdd4_result", setAdd4_result); + expected.put("setAdd4_value", setAdd4_value); + expected.put("setAdd5_result1", setAdd5_result1); + expected.put("setAdd5_result2", setAdd5_result2); + expected.put("setAdd5_result3", setAdd5_result3); + expected.put("setAddList2", setAddList2); + expected.put("setAdd5_value", setAdd5_value); + expected.put("setAdd6_result", setAdd6_result); + expected.put("setAdd6_value", setAdd6_value); + expected.put("setAdd7_result", setAdd7_result); + expected.put("setAdd7_value", setAdd7_value); + expected.put("setAdd8_value", setAdd8_value); + + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expected.toString(), actual.toString()); + } + + @Test + public void setsSort_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var set2 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var set1_asc = set1.clone(); // clone set1, size = 6 + var set1_desc = set1.clone(); // clone set1, size = 6 + set1.sort(); // sort set1 -> asc + set1_asc.sort(true); // sort set1_asc -> asc + set1_desc.sort(false); // sort set1_desc -> desc + var set3 = set2.toSorted(); // toSorted set3 -> asc + var set3_asc = set2.toSorted(true); // toSorted set3 -> asc + var set3_desc = set2.toSorted(false); // toSorted set3 -> desc + return { + "set1": set1, + "set1_asc": set1_asc, + "set1_desc": set1_desc, + "set2": set2, + "set3": set3, + "set3_asc": set3_asc, + "set3_desc": set3_desc, + } + """; + ArrayList list = new ArrayList<>(List.of("C", "B", "A", 34567, "hello", 34)); + Set expected = new LinkedHashSet<>(list); + ArrayList listSortAsc = new ArrayList<>(List.of(34, 34567, "A", "B", "C", "hello")); + Set expectedAsc = new LinkedHashSet<>(listSortAsc); + ArrayList listSortDesc = new ArrayList<>(List.of("hello", "C", "B", "A", 34567, 34)); + Set expectedDesc = new LinkedHashSet<>(listSortDesc); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set1").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set1_asc").toString()); + assertEquals(expectedDesc.toString(), ((LinkedHashMap)actual).get("set1_desc").toString()); + assertEquals(expected.toString(), ((LinkedHashMap)actual).get("set2").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set3").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set3_asc").toString()); + assertEquals(expectedDesc.toString(), ((LinkedHashMap)actual).get("set3_desc").toString()); + } + + @Test + public void setsContains_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var result1 = set1.contains("A"); // return true + var result2 = set1.contains("H"); // return false + return { + "set1": set1, + "result1": result1, + "result2": result2 + } + """; + List listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34)); + Set expectedSet = new LinkedHashSet<>(listOrigin); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(expectedSet.toString(), ((LinkedHashMap)actual).get("set1").toString()); + assertEquals(true, ((LinkedHashMap)actual).get("result1")); + assertEquals(false, ((LinkedHashMap)actual).get("result2")); + } + + @Test + public void setsToList_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + var set1 = toSet(msg.list); // create new from method toSet(List list) no sort, size = 6 ("A" and "C" is duplicated) + var tolist = set1.toList(); // create new List from Set, size = 6 + return { + "list": msg.list, + "set1": set1, + "tolist": tolist + } + """; + List listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34)); + Set expectedSet = new LinkedHashSet<>(listOrigin); + List expectedToList = new ArrayList<>(expectedSet); + Object actual = invokeScript(evalScript(decoderStr), msgStr); + assertEquals(listOrigin.toString(), ((LinkedHashMap)actual).get("list").toString()); + assertEquals(expectedSet.toString(), ((LinkedHashMap)actual).get("set1").toString()); + assertEquals(expectedToList.toString(), ((LinkedHashMap)actual).get("tolist").toString()); + } + @Test public void arraysWillCauseArrayIndexOutOfBoundsException_Test() throws ExecutionException, InterruptedException { msgStr = """ @@ -2399,25 +2679,19 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { list.add(0x35); return isList(list); """); + } + + @Test + public void isSet_Test() throws ExecutionException, InterruptedException { + msgStr = """ + {"list": ["C", "B", "A", 34567, "B", "C", "hello", 34]} + """; + decoderStr = """ + return isSet(toSet(msg.list)); // return true + """; Object actual = invokeScript(evalScript(decoderStr), msgStr); assertInstanceOf(Boolean.class, actual); assertTrue((Boolean) actual); - decoderStr = String.format(""" - var list = []; - list.add(0x35); - return isMap(list); - """); - actual = invokeScript(evalScript(decoderStr), msgStr); - assertInstanceOf(Boolean.class, actual); - assertFalse((Boolean) actual); - decoderStr = String.format(""" - var list = []; - list.add(0x35); - return isArray(list); - """); - actual = invokeScript(evalScript(decoderStr), msgStr); - assertInstanceOf(Boolean.class, actual); - assertFalse((Boolean) actual); } @Test @@ -2435,16 +2709,6 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { Object actual = invokeScript(evalScript(decoderStr), msgStr); assertInstanceOf(Boolean.class, actual); assertTrue((Boolean) actual); - decoderStr = """ - var array = new int[3]; - array[0] = 1; - array[1] = 2; - array[2] = 3; - return isList(array); - """; - actual = invokeScript(evalScript(decoderStr), msgStr); - assertInstanceOf(Boolean.class, actual); - assertFalse((Boolean) actual); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java index da3b214afb..e70a0cd37c 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/ie/ExportImportServiceSqlTest.java @@ -66,6 +66,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; @@ -203,11 +204,12 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { AssetProfile assetProfile = createAssetProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Asset profile 1"); Asset asset = createAsset(tenantId1, null, assetProfile.getId(), "Asset 1"); DeviceProfile deviceProfile = createDeviceProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Device profile 1"); - Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + Device device = createDevice(tenantId1, null, deviceProfile.getId(), "Device 1", firmware.getId(), null); CalculatedField calculatedField = createCalculatedField(tenantId1, device.getId(), asset.getId()); Map entitiesExportData = Stream.of(customer.getId(), asset.getId(), device.getId(), - ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId()) + ruleChain.getId(), dashboard.getId(), assetProfile.getId(), deviceProfile.getId(), firmware.getId()) .map(entityId -> { try { return exportEntity(tenantAdmin1, entityId, EntityExportSettings.builder() @@ -275,12 +277,17 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { verify(tbClusterService).sendNotificationMsgToEdge(any(), any(), eq(importedDeviceProfile.getId()), any(), any(), eq(EdgeEventActionType.ADDED), any()); verify(otaPackageStateService).update(eq(importedDeviceProfile), eq(false), eq(false)); + OtaPackage importedFirmware = (OtaPackage) importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.OTA_PACKAGE)).getSavedEntity(); + verify(entityActionService).logEntityAction(any(), eq(importedFirmware.getId()), eq(importedFirmware), + any(), eq(ActionType.ADDED), isNull()); + Device importedDevice = (Device) importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.DEVICE)).getSavedEntity(); verify(entityActionService).logEntityAction(any(), eq(importedDevice.getId()), eq(importedDevice), any(), eq(ActionType.ADDED), isNull()); verify(tbClusterService).onDeviceUpdated(eq(importedDevice), isNull()); importEntity(tenantAdmin2, getAndClone(entitiesExportData, EntityType.DEVICE)); verify(tbClusterService, Mockito.never()).onDeviceUpdated(eq(importedDevice), eq(importedDevice)); + assertThat(importedDevice.getFirmwareId()).isEqualTo(importedFirmware.getId()); // calculated field of imported device: List calculatedFields = calculatedFieldService.findCalculatedFieldsByEntityId(tenantId2, importedDevice.getId()); @@ -318,14 +325,15 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { assetProfile = assetProfileService.saveAssetProfile(assetProfile); DeviceProfile deviceProfile = createDeviceProfile(tenantId1, ruleChain.getId(), dashboard.getId(), "Device profile 1"); - Device device = createDevice(tenantId1, customer.getId(), deviceProfile.getId(), "Device 1"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + Device device = createDevice(tenantId1, customer.getId(), deviceProfile.getId(), "Device 1", firmware.getId(), null); EntityView entityView = createEntityView(tenantId1, customer.getId(), device.getId(), "Entity view 1"); CalculatedField calculatedField = createCalculatedField(tenantId1, device.getId(), device.getId()); Map ids = new HashMap<>(); for (EntityId entityId : List.of(customer.getId(), ruleChain.getId(), dashboard.getId(), assetProfile.getId(), asset.getId(), - deviceProfile.getId(), device.getId(), entityView.getId(), ruleChain.getId(), dashboard.getId())) { + deviceProfile.getId(), firmware.getId(), device.getId(), entityView.getId(), ruleChain.getId(), dashboard.getId())) { EntityExportData exportData = exportEntity(getSecurityUser(tenantAdmin1), entityId); EntityImportResult importResult = importEntity(getSecurityUser(tenantAdmin2), exportData, EntityImportSettings.builder() .saveCredentials(false) @@ -359,12 +367,17 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { assertThat(exportedDeviceProfile.getDefaultRuleChainId()).isEqualTo(ruleChain.getId()); assertThat(exportedDeviceProfile.getDefaultDashboardId()).isEqualTo(dashboard.getId()); - EntityExportData entityExportData = exportEntity(tenantAdmin2, (DeviceId) ids.get(device.getId())); + OtaPackage exportedFirmware = (OtaPackage) exportEntity(tenantAdmin2, (OtaPackageId) ids.get(firmware.getId())).getEntity(); + assertThat(exportedFirmware.getDeviceProfileId()).isEqualTo(exportedDeviceProfile.getId()); + assertThat(exportedFirmware.getId()).isEqualTo(firmware.getId()); + + EntityExportData entityExportData = exportEntity(tenantAdmin2, (DeviceId) ids.get(device.getId())); Device exportedDevice = entityExportData.getEntity(); assertThat(exportedDevice.getCustomerId()).isEqualTo(customer.getId()); assertThat(exportedDevice.getDeviceProfileId()).isEqualTo(deviceProfile.getId()); + assertThat(exportedDevice.getFirmwareId()).isEqualTo(firmware.getId()); - List calculatedFields = ((DeviceExportData) entityExportData).getCalculatedFields(); + List calculatedFields = entityExportData.getCalculatedFields(); assertThat(calculatedFields.size()).isOne(); CalculatedField field = calculatedFields.get(0); assertThat(field.getName()).isEqualTo(calculatedField.getName()); @@ -380,13 +393,15 @@ public class ExportImportServiceSqlTest extends AbstractControllerTest { deviceProfileService.saveDeviceProfile(importedDeviceProfile); } - protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name) { + protected Device createDevice(TenantId tenantId, CustomerId customerId, DeviceProfileId deviceProfileId, String name, OtaPackageId firmwareId, OtaPackageId softwareId) { Device device = new Device(); device.setTenantId(tenantId); device.setCustomerId(customerId); device.setName(name); device.setLabel("lbl"); device.setDeviceProfileId(deviceProfileId); + device.setFirmwareId(firmwareId); + device.setSoftwareId(softwareId); DeviceData deviceData = new DeviceData(); deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); device.setDeviceData(deviceData); diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index c7d4068210..461ca5a2ec 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -116,8 +116,8 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static org.thingsboard.server.controller.TbResourceControllerTest.TEST_DATA; import static org.thingsboard.server.controller.TbResourceControllerTest.JS_TEST_FILE_NAME; +import static org.thingsboard.server.controller.TbResourceControllerTest.TEST_DATA; @DaoSqlTest public class VersionControlTest extends AbstractControllerTest { @@ -262,19 +262,24 @@ public class VersionControlTest extends AbstractControllerTest { } @Test - public void testDeviceVc_withProfile_betweenTenants() throws Exception { + public void testDeviceVc_withProfileAndOtaPackage_betweenTenants() throws Exception { DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile of tenant 1"); createVersion("profiles", EntityType.DEVICE_PROFILE); - Device device = createDevice(null, deviceProfile.getId(), "Device of tenant 1", "test1"); - String versionId = createVersion("devices", EntityType.DEVICE); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); + Device device = createDevice(null, deviceProfile.getId(), "Device of tenant 1", "test1", newDevice -> { + newDevice.setFirmwareId(firmware.getId()); + newDevice.setSoftwareId(software.getId()); + }); + String versionId = createVersion("devices with ota", EntityType.DEVICE, EntityType.OTA_PACKAGE); DeviceCredentials deviceCredentials = findDeviceCredentials(device.getId()); DeviceCredentials newCredentials = new DeviceCredentials(deviceCredentials); newCredentials.setCredentialsId("new access token"); // updating access token to avoid constraint errors on import doPost("/api/device/credentials", newCredentials, DeviceCredentials.class); - assertThat(listVersions()).extracting(EntityVersion::getName).containsExactly("devices", "profiles"); + assertThat(listVersions()).extracting(EntityVersion::getName).containsExactly("devices with ota", "profiles"); loginTenant2(); - Map result = loadVersion(versionId, EntityType.DEVICE, EntityType.DEVICE_PROFILE); + Map result = loadVersion(versionId, EntityType.DEVICE, EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE); assertThat(result.get(EntityType.DEVICE).getCreated()).isEqualTo(1); assertThat(result.get(EntityType.DEVICE_PROFILE).getCreated()).isEqualTo(1); @@ -293,6 +298,13 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedCredentials.getCredentialsId()).isEqualTo(deviceCredentials.getCredentialsId()); assertThat(importedCredentials.getCredentialsValue()).isEqualTo(deviceCredentials.getCredentialsValue()); assertThat(importedCredentials.getCredentialsType()).isEqualTo(deviceCredentials.getCredentialsType()); + + OtaPackage importedFirmwareOta = findOtaPackage(firmware.getTitle()); + OtaPackage importedSoftwareOta = findOtaPackage(software.getTitle()); + checkImportedEntity(tenantId1, firmware, tenantId2, importedFirmwareOta); + checkImportedOtaPackageData(firmware, importedFirmwareOta); + checkImportedEntity(tenantId1, software, tenantId2, importedSoftwareOta); + checkImportedOtaPackageData(software, importedSoftwareOta); } @Test @@ -653,6 +665,57 @@ public class VersionControlTest extends AbstractControllerTest { assertThat(importedCalculatedField.getType()).isEqualTo(calculatedField.getType()); } + @Test + public void testOtaPackageVc_sameTenant() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile v1.0"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); + String versionId = createVersion("ota packages", EntityType.OTA_PACKAGE); + + OtaPackage firmwareOta = findOtaPackage(firmware.getTitle()); + OtaPackage softwareOta = findOtaPackage(software.getTitle()); + + loadVersion(versionId, EntityType.OTA_PACKAGE); + OtaPackage importedFirmwareOta = findOtaPackage(firmwareOta.getTitle()); + OtaPackage importedSoftwareOta = findOtaPackage(softwareOta.getTitle()); + checkImportedEntity(tenantId1, firmwareOta, tenantId1, importedFirmwareOta); + checkImportedOtaPackageData(firmwareOta, importedFirmwareOta); + checkImportedEntity(tenantId1, softwareOta, tenantId1, importedSoftwareOta); + checkImportedOtaPackageData(softwareOta, importedSoftwareOta); + } + + @Test + public void testOtaPackageVcWithProfile_betweenTenants() throws Exception { + DeviceProfile deviceProfile = createDeviceProfile(null, null, "Device profile v1.0"); + OtaPackage firmware = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.FIRMWARE); + OtaPackage software = createOtaPackage(tenantId1, deviceProfile.getId(), OtaPackageType.SOFTWARE); + deviceProfile.setFirmwareId(firmware.getId()); + deviceProfile.setSoftwareId(software.getId()); + deviceProfile = doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + String versionId = createVersion("ota packages", EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE); + + loginTenant2(); + loadVersion(versionId, EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE); + DeviceProfile importedProfile = findDeviceProfile(deviceProfile.getName()); + OtaPackage importedFirmwareOta = findOtaPackage(firmware.getTitle()); + OtaPackage importedSoftwareOta = findOtaPackage(software.getTitle()); + checkImportedEntity(tenantId1, deviceProfile, tenantId2, importedProfile); + checkImportedDeviceProfileData(deviceProfile, importedProfile); + checkImportedEntity(tenantId1, firmware, tenantId2, importedFirmwareOta); + checkImportedOtaPackageData(firmware, importedFirmwareOta); + checkImportedEntity(tenantId1, software, tenantId2, importedSoftwareOta); + checkImportedOtaPackageData(software, importedSoftwareOta); + assertThat(importedProfile.getFirmwareId()).isEqualTo(importedFirmwareOta.getId()); + assertThat(importedProfile.getSoftwareId()).isEqualTo(importedSoftwareOta.getId()); + } + + protected void checkImportedOtaPackageData(OtaPackage otaPackage, OtaPackage importedOtaPackage) { + assertThat(importedOtaPackage.getName()).isEqualTo(otaPackage.getName()); + assertThat(importedOtaPackage.getTag()).isEqualTo(otaPackage.getTag()); + assertThat(importedOtaPackage.getType()).isEqualTo(otaPackage.getType()); + assertThat(importedOtaPackage.getFileName()).isEqualTo(otaPackage.getFileName()); + } + @Test public void testResourceVc_sameTenant() throws Exception { TbResourceInfo resourceInfo = createResource("Test resource"); @@ -923,6 +986,7 @@ public class VersionControlTest extends AbstractControllerTest { otaPackage.setDeviceProfileId(deviceProfileId); otaPackage.setType(type); otaPackage.setTitle("My " + type); + otaPackage.setTag("My " + type); otaPackage.setVersion("v1.0"); otaPackage.setFileName("filename.txt"); otaPackage.setContentType("text/plain"); @@ -933,6 +997,10 @@ public class VersionControlTest extends AbstractControllerTest { return otaPackageService.saveOtaPackage(otaPackage); } + private OtaPackage findOtaPackage(String title) throws Exception { + return doGetTypedWithPageLink("/api/otaPackages?", new TypeReference>() {}, new PageLink(100, 0, title)).getData().get(0); + } + protected Dashboard createDashboard(CustomerId customerId, String name) { Dashboard dashboard = new Dashboard(); dashboard.setTitle(name); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java index fb430c8963..e053ad9c9e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/AbstractCoapAttributesIntegrationTest.java @@ -35,6 +35,7 @@ import org.thingsboard.server.common.data.device.profile.DefaultCoapDeviceTypeCo import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.SingleEntityFilter; @@ -170,7 +171,7 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap protected void processJsonTestRequestAttributesValuesFromTheServer() throws Exception { client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); @@ -200,7 +201,7 @@ public abstract class AbstractCoapAttributesIntegrationTest extends AbstractCoap protected void processProtoTestRequestAttributesValuesFromTheServer() throws Exception { client = new CoapTestClient(accessToken, FeatureType.ATTRIBUTES); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java index 4943d1b5c3..3845cc0c3c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/client/CoapClientIntegrationTest.java @@ -29,6 +29,7 @@ import org.junit.Before; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.SingleEntityFilter; @@ -161,7 +162,7 @@ public class CoapClientIntegrationTest extends AbstractCoapIntegrationTest { protected void processTestRequestAttributesValuesFromTheServer(boolean confirmable) throws Exception { client = createClientForFeatureWithConfirmableParameter(FeatureType.ATTRIBUTES, confirmable); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); List csKeys = getEntityKeys(CLIENT_ATTRIBUTE); List shKeys = getEntityKeys(SHARED_ATTRIBUTE); List keys = new ArrayList<>(); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java index 587f9ade1c..ddf8bca43f 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java @@ -21,8 +21,14 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; +import org.eclipse.leshan.client.LeshanClient; import org.eclipse.leshan.client.object.Security; +import org.eclipse.leshan.client.servers.LwM2mServer; import org.eclipse.leshan.core.ResponseCode; +import org.eclipse.leshan.core.request.ContentFormat; +import org.eclipse.leshan.core.response.ErrorCallback; +import org.eclipse.leshan.core.response.ResponseCallback; +import org.eclipse.leshan.core.response.SendResponse; import org.eclipse.leshan.server.registration.Registration; import org.junit.After; import org.junit.Assert; @@ -57,6 +63,7 @@ import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.Abstrac import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MBootstrapServerCredential; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.NoSecLwM2MBootstrapServerCredential; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -73,6 +80,7 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.transport.AbstractTransportIntegrationTest; import org.thingsboard.server.transport.lwm2m.client.LwM2MTestClient; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; +import org.thingsboard.server.transport.lwm2m.server.client.ResourceUpdateResult; import org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; @@ -82,6 +90,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -93,6 +102,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -340,7 +350,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); SingleEntityFilter sef = new SingleEntityFilter(); - sef.setSingleEntity(device.getId()); + sef.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); LatestValueCmd latestCmd = new LatestValueCmd(); latestCmd.setKeys(Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "batteryLevel"))); EntityDataQuery edq = new EntityDataQuery(sef, new EntityDataPageLink(1, 0, null, null), @@ -351,7 +361,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte getWsClient().waitForReply(); getWsClient().registerWaitForUpdate(); - this.createNewClient(security, null, false, endpoint, null, queueMode, device.getId().getId().toString()); + this.createNewClient(security, null, false, endpoint, null, queueMode, device.getId().getId().toString(), null); awaitObserveReadAll(1, lwM2MTestClient.getDeviceIdStr()); String msg = getWsClient().waitForUpdate(); @@ -407,7 +417,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); SingleEntityFilter sef = new SingleEntityFilter(); - sef.setSingleEntity(device.getId()); + sef.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); LatestValueCmd latestCmd = new LatestValueCmd(); String key1 = "pkgname"; String key2 = "pkgversion"; @@ -422,7 +432,7 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte getWsClient().waitForReply(); getWsClient().registerWaitForUpdate(); - this.createNewClient(security, null, false, endpoint, null, true, device.getId().getId().toString()); + this.createNewClient(security, null, false, endpoint, null, true, device.getId().getId().toString(), null); awaitObserveReadAll(cntObserve, lwM2MTestClient.getDeviceIdStr()); String msg = getWsClient().waitForUpdate(); @@ -543,16 +553,17 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte public void createNewClient(Security security, Security securityBs, boolean isRpc, String endpoint, String deviceIdStr) throws Exception { - this.createNewClient(security, securityBs, isRpc, endpoint, null, false, deviceIdStr); + this.createNewClient(security, securityBs, isRpc, endpoint, null, false, deviceIdStr, null); } public void createNewClient(Security security, Security securityBs, boolean isRpc, String endpoint, Integer clientDtlsCidLength, String deviceIdStr) throws Exception { - this.createNewClient(security, securityBs, isRpc, endpoint, clientDtlsCidLength, false, deviceIdStr); + this.createNewClient(security, securityBs, isRpc, endpoint, clientDtlsCidLength, false, deviceIdStr, null); } public void createNewClient(Security security, Security securityBs, boolean isRpc, - String endpoint, Integer clientDtlsCidLength, boolean queueMode, String deviceIdStr) throws Exception { + String endpoint, Integer clientDtlsCidLength, boolean queueMode, + String deviceIdStr, Integer value3_0_9) throws Exception { this.clientDestroy(false); lwM2MTestClient = new LwM2MTestClient(this.executor, endpoint, resources); @@ -560,11 +571,86 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte int clientPort = socket.getLocalPort(); lwM2MTestClient.init(security, securityBs, clientPort, isRpc, this.defaultLwM2mUplinkMsgHandlerTest, this.clientContextTest, - clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR); + clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR, value3_0_9); } lwM2MTestClient.setDeviceIdStr(deviceIdStr); } + /** + * Test: "/3/0/9" value = 44 (constant); count = 10; send from client to telemetry without observe + * @param security + * @param deviceCredentials + * @param endpoint + * @param queueMode + * @throws Exception + */ + public void testConnectionWithoutObserveWithDataReceivedSingleTelemetry(Security security, + LwM2MDeviceCredentials deviceCredentials, + String endpoint, + boolean queueMode) throws Exception { + Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(TELEMETRY_WITH_ONE_OBSERVE, getBootstrapServerCredentialsNoSec(NONE)); + DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + endpoint, transportConfiguration); + Device device = createLwm2mDevice(deviceCredentials, endpoint, deviceProfile.getId()); + + + + SingleEntityFilter sef = new SingleEntityFilter(); + sef.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); + LatestValueCmd latestCmd = new LatestValueCmd(); + latestCmd.setKeys(Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "batteryLevel"))); + EntityDataQuery edq = new EntityDataQuery(sef, new EntityDataPageLink(1, 0, null, null), + Collections.emptyList(), Collections.emptyList(), Collections.emptyList()); + + EntityDataCmd cmd = new EntityDataCmd(1, edq, null, latestCmd, null); + getWsClient().send(cmd); + getWsClient().waitForReply(); + + getWsClient().registerWaitForUpdate(); + + this.createNewClient(security, null, false, endpoint, null, queueMode, device.getId().getId().toString(), 44); + awaitObserveReadAll(1, lwM2MTestClient.getDeviceIdStr()); + + LeshanClient leshanClient = lwM2MTestClient.getLeshanClient(); + Map registeredServers = leshanClient.getRegisteredServers(); + List paths = List.of("/3/0/9"); + int cntUpdate = 10; + int cntLast = cntUpdate; + for (final LwM2mServer server : registeredServers.values()) { + log.info("Sending Data to {} using {}.", server, ContentFormat.SENML_CBOR); + ResponseCallback responseCallback = (response) -> { + if (response.isSuccess()) + log.warn("Data sent successfully to {} [{}].", server, response.getCode()); + else + log.warn("Send data to {} failed [{}] : {}.", server, response.getCode(), + response.getErrorMessage() == null ? "" : response.getErrorMessage()); + }; + ErrorCallback errorCallback = (e) -> log.warn("Unable to send data to {}.", server, e); + while(cntLast > 0) { + leshanClient.getSendService().sendData(server, ContentFormat.SENML_CBOR, paths, + 2000, responseCallback, errorCallback); + cntLast-- ; + } + } + + + verify(defaultUplinkMsgHandlerTest, timeout(10000).atLeast(cntUpdate)) + .updateAttrTelemetry(Mockito.any(ResourceUpdateResult.class), eq(null)); + + String msg = getWsClient().waitForUpdate(); + EntityDataUpdate update = JacksonUtil.fromString(msg, EntityDataUpdate.class); + Assert.assertEquals(1, update.getCmdId()); + List eData = update.getUpdate(); + Assert.assertNotNull(eData); + Assert.assertEquals(1, eData.size()); + Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); + Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); + var tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("batteryLevel"); + assertThat(Long.parseLong(tsValue.getValue()), instanceOf(Long.class)); + int expected = 44; + assertEquals(expected, Long.parseLong(tsValue.getValue())); + } + + private void clientDestroy(boolean isAfter) { try { if (lwM2MTestClient != null && lwM2MTestClient.getLeshanClient() != null) { diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java index 06e41fe29f..2ae1432cac 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java @@ -144,7 +144,7 @@ public class LwM2MTestClient { public void init(Security security, Security securityBs, int port, boolean isRpc, LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler, LwM2mClientContext clientContext, Integer cIdLength, boolean queueMode, - boolean supportFormatOnly_SenMLJSON_SenMLCBOR) throws InvalidDDFFileException, IOException { + boolean supportFormatOnly_SenMLJSON_SenMLCBOR, Integer value3_0_9) throws InvalidDDFFileException, IOException { Assert.assertNull("client already initialized", leshanClient); this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler; this.clientContext = clientContext; @@ -197,7 +197,7 @@ public class LwM2MTestClient { initializer.setInstancesForObject(SERVER, lwm2mServer); } - initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor)); + initializer.setInstancesForObject(DEVICE, lwM2MDevice = new SimpleLwM2MDevice(executor, value3_0_9)); initializer.setInstancesForObject(FIRMWARE, fwLwM2MDevice = new FwLwM2MDevice()); initializer.setInstancesForObject(SOFTWARE_MANAGEMENT, swLwM2MDevice = new SwLwM2MDevice()); initializer.setClassForObject(ACCESS_CONTROL, DummyInstanceEnabler.class); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java index 447cc051fc..5157d5597d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SimpleLwM2MDevice.java @@ -85,18 +85,22 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl */ private static Map errorCode = Map.of(0, 0L); // 0-32 + private Integer value3_0_9; public SimpleLwM2MDevice() { } - public SimpleLwM2MDevice(ScheduledExecutorService executorService) { + public SimpleLwM2MDevice(ScheduledExecutorService executorService, Integer value3_0_9) { + this.value3_0_9 = value3_0_9; try { - executorService.scheduleWithFixedDelay(() -> { - fireResourceChange(9); - fireResourceChange(20); - } - , 1, 1, TimeUnit.SECONDS); // 2 sec + if ( this.value3_0_9 == null) { + executorService.scheduleWithFixedDelay(() -> { + fireResourceChange(9); + fireResourceChange(20); + } + , 1, 1, TimeUnit.SECONDS); // 2 sec // , 1800000, 1800000, TimeUnit.MILLISECONDS); // 30 MIN + } } catch (Throwable e) { log.error("[{}]Throwable", e.toString()); e.printStackTrace(); @@ -211,8 +215,14 @@ public class SimpleLwM2MDevice extends BaseInstanceEnabler implements Destroyabl } private int getBatteryLevel() { - int valBattery = randomIterator.nextInt(); - log.trace("Send from client [3/0/9] val: [{}]", valBattery); + int valBattery; + if (this.value3_0_9 == null) { + valBattery = randomIterator.nextInt(); + log.trace("Send from client [3/0/9] val: [{}]", valBattery); + } else { + valBattery = this.value3_0_9; + log.warn("Send from client [3/0/9] constant value: [{}]", valBattery); + } return valBattery; } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java new file mode 100644 index 0000000000..8cb5e06248 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationDataReceivedFromClientTest.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.rpc.sql; + +import org.junit.Test; +import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MDeviceCredentials; +import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; + +public class RpcLwm2mIntegrationDataReceivedFromClientTest extends AbstractSecurityLwM2MIntegrationTest { + + @Test + public void testWithNoSecConnectLwm2mSuccessAndObserveTelemetry() throws Exception { + String clientEndpoint = CLIENT_ENDPOINT_NO_SEC; + LwM2MDeviceCredentials clientCredentials = getDeviceCredentialsNoSec(createNoSecClientCredentials(clientEndpoint)); + super.testConnectionWithoutObserveWithDataReceivedSingleTelemetry(SECURITY_NO_SEC, clientCredentials, clientEndpoint, false); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index 40afe76e01..5c70f05b94 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -34,6 +34,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityKey; @@ -339,7 +340,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt MqttTestClient client = new MqttTestClient(); client.connectAndWait(accessToken); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(savedDevice.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(savedDevice.getId())); String clientKeysStr = "clientStr,clientBool,clientDbl,clientLong,clientJson"; String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); @@ -426,7 +427,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt }); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(device.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List clientKeysList = List.of(clientKeysStr.split(",")); List sharedKeysList = List.of(sharedKeysStr.split(",")); @@ -481,7 +482,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt assertNotNull(device); SingleEntityFilter dtf = new SingleEntityFilter(); - dtf.setSingleEntity(device.getId()); + dtf.setSingleEntity(AliasEntityId.fromEntityId(device.getId())); String sharedKeysStr = "sharedStr,sharedBool,sharedDbl,sharedLong,sharedJson"; List sharedKeysList = List.of(sharedKeysStr.split(",")); List csKeys = getEntityKeys(clientKeysList, CLIENT_ATTRIBUTE); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java index 05a5843626..13e9dcaf0e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java @@ -29,6 +29,7 @@ import org.eclipse.paho.mqttv5.common.packet.MqttReturnCode; import org.eclipse.paho.mqttv5.common.packet.MqttWireMessage; import org.junit.Assert; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; @@ -67,7 +68,7 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataTyp import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt64; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt8; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; /** * Created by nickAS21 on 12.01.23 @@ -112,31 +113,44 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte long value = bdSeq = 0; clientWithCorrectNodeAccessTokenWithNDEATH(ts, value); } + public void clientWithCorrectNodeAccessTokenWithNDEATH(Long alias) throws Exception { + long ts = calendar.getTimeInMillis(); + long value = bdSeq = 0; + clientMqttV5ConnectWithNDEATH(ts, value,alias); + } public void clientWithCorrectNodeAccessTokenWithNDEATH(long ts, long value) throws Exception { - IMqttToken connectionResult = clientConnectWithNDEATH(ts, value); + IMqttToken connectionResult = clientMqttV5ConnectWithNDEATH(ts, value, -1L); MqttWireMessage response = connectionResult.getResponse(); Assert.assertEquals(MESSAGE_TYPE_CONNACK, response.getType()); MqttConnAck connAckMsg = (MqttConnAck) response; Assert.assertEquals(MqttReturnCode.RETURN_CODE_SUCCESS, connAckMsg.getReturnCode()); } - public IMqttToken clientConnectWithNDEATH(long ts, long value, String... nameSpaceBad) throws Exception { - String key = keysBdSeq; + public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, Long alias, String... nameSpaceBad) throws Exception { + return clientMqttV5ConnectWithNDEATH(ts, value, null, alias, nameSpaceBad); + } + public IMqttToken clientMqttV5ConnectWithNDEATH(long ts, long value, String metricName, Long alias, String... nameSpaceBad) throws Exception { + String key = metricName == null ? keysBdSeq : metricName; MetricDataType metricDataType = Int64; SparkplugBProto.Payload.Builder deathPayload = SparkplugBProto.Payload.newBuilder() .setTimestamp(calendar.getTimeInMillis()); - deathPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + deathPayload.addMetrics(createMetric(value, ts, key, metricDataType, alias)); byte[] deathBytes = deathPayload.build().toByteArray(); this.client = new MqttV5TestClient(); this.mqttCallback = new SparkplugMqttCallback(); this.client.setCallback(this.mqttCallback); MqttConnectionOptions options = new MqttConnectionOptions(); + // If the MQTT client is using MQTT v5.0, the Edge Node’s MQTT CONNECT packet MUST set the Clean Start flag to true and the Session Expiry Interval to 0 + options.setCleanStart(true); + options.setSessionExpiryInterval(0L); options.setUserName(gatewayAccessToken); - String nameSpace = nameSpaceBad.length == 0 ? NAMESPACE : nameSpaceBad[0]; + String nameSpace = nameSpaceBad.length == 0 ? TOPIC_ROOT_SPB_V_1_0 : nameSpaceBad[0]; String topic = nameSpace + "/" + groupId + "/" + SparkplugMessageType.NDEATH.name() + "/" + edgeNode; + // The NDEATH message MUST set the MQTT Will QoS to 1 and Retained flag to false MqttMessage msg = new MqttMessage(); msg.setId(0); + msg.setQos(1); msg.setPayload(deathBytes); options.setWill(topic, msg); return client.connect(options); @@ -148,19 +162,19 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte MetricDataType metricDataType = Int32; String key = "Node Metric int32"; int valueDeviceInt32 = 1024; - SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType); + SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L); SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getBdSeqNum()); payloadBirthNode.addMetrics(metric); payloadBirthNode.setTimestamp(ts); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } valueDeviceInt32 = 4024; - metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32); + metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); for (int i = 0; i < cntDevices; i++) { SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) @@ -169,7 +183,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte payloadBirthDevice.addMetrics(metric); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, payloadBirthDevice.build().toByteArray(), 0, false); AtomicReference device = new AtomicReference<>(); await(alias + "find device [" + deviceName + "] after created") @@ -187,6 +201,49 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte return devices; } + protected List connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(long ts) throws Exception { + List devices = new ArrayList<>(); + Long alias = 0L; + clientWithCorrectNodeAccessTokenWithNDEATH(alias++); + MetricDataType metricDataType = Int32; + String key = "Node Metric int32"; + int valueDeviceInt32 = 1024; + SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, alias++); + SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getBdSeqNum()); + payloadBirthNode.addMetrics(metric); + payloadBirthNode.setTimestamp(ts); + if (client.isConnected()) { + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + payloadBirthNode.build().toByteArray(), 0, false); + } + + valueDeviceInt32 = 4024; + metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, alias++); + SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() + .setTimestamp(ts) + .setSeq(getSeqNum()); + String deviceName = deviceId + "_" + 1; + + payloadBirthDevice.addMetrics(metric); + if (client.isConnected()) { + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, + payloadBirthDevice.build().toByteArray(), 0, false); + AtomicReference device = new AtomicReference<>(); + await(alias + "find device [" + deviceName + "] after created") + .atMost(200, TimeUnit.SECONDS) + .until(() -> { + device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); + return device.get() != null; + }); + devices.add(device.get()); + } + + Assert.assertEquals(1, devices.size()); + return devices; + } + protected long getBdSeqNum() throws Exception { if (bdSeq == 256) { bdSeq = 0; @@ -207,16 +264,20 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte .setTimestamp(calendar.getTimeInMillis()); long ts = calendar.getTimeInMillis() - PUBLISH_TS_DELTA_MS; long valueBdSec = getBdSeqNum(); - payloadBirthNode.addMetrics(createMetric(valueBdSec, ts, keysBdSeq, Int64)); + payloadBirthNode.addMetrics(createMetric(valueBdSec, ts, keysBdSeq, Int64, -1L)); listKeys.add(SparkplugMessageType.NBIRTH.name() + " " + keysBdSeq); - payloadBirthNode.addMetrics(createMetric(false, ts, keyNodeRebirth, MetricDataType.Boolean)); + payloadBirthNode.addMetrics(createMetric(false, ts, keyNodeRebirth, MetricDataType.Boolean, -1L)); listKeys.add(keyNodeRebirth); - payloadBirthNode.addMetrics(createMetric(metricValue, ts, metricKey, metricDataType)); + if (StringUtils.isNotBlank(metricKey)) { + payloadBirthNode.addMetrics(createMetric(metricValue, ts, metricKey, metricDataType, -1L)); + } else { + payloadBirthNode.addMetrics(createMetric(metricValue, ts, metricKey, metricDataType, 4L)); + } listKeys.add(metricKey); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, payloadBirthNode.build().toByteArray(), 0, false); } return listKeys; @@ -297,7 +358,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte private TsKvEntry createdAddMetricTsKvLong(SparkplugBProto.Payload.Builder dataPayload, String key, Object value, long ts, MetricDataType metricDataType) throws ThingsboardException { TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new LongDataEntry(key, Long.valueOf(String.valueOf(value)))); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } @@ -305,7 +366,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte long ts, MetricDataType metricDataType) throws ThingsboardException { Double dd = Double.parseDouble(Float.toString(value)); TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new DoubleDataEntry(key, dd)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } @@ -313,21 +374,21 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte long ts, MetricDataType metricDataType) throws ThingsboardException { Long l = Double.valueOf(value).longValue(); TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new LongDataEntry(key, l)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } private TsKvEntry createdAddMetricTsKvBoolean(SparkplugBProto.Payload.Builder dataPayload, String key, boolean value, long ts, MetricDataType metricDataType) throws ThingsboardException { TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new BooleanDataEntry(key, value)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } private TsKvEntry createdAddMetricTsKvString(SparkplugBProto.Payload.Builder dataPayload, String key, String value, long ts, MetricDataType metricDataType) throws ThingsboardException { TsKvEntry tsKvEntry = new BasicTsKvEntry(ts, new StringDataEntry(key, value)); - dataPayload.addMetrics(createMetric(value, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(value, ts, key, metricDataType, -1L)); return tsKvEntry; } @@ -348,7 +409,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte if (nodeArray.size() > 0) { Optional tsKvEntryOptional = Optional.of(new BasicTsKvEntry(ts, new JsonDataEntry(key, nodeArray.toString()))); if (tsKvEntryOptional.isPresent()) { - dataPayload.addMetrics(createMetric(values, ts, key, metricDataType)); + dataPayload.addMetrics(createMetric(values, ts, key, metricDataType, -1L)); listTsKvEntry.add(tsKvEntryOptional.get()); listKeys.add(key); } @@ -416,7 +477,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte return java.util.UUID.randomUUID().toString(); } - public class SparkplugMqttCallback implements MqttCallback { + public class SparkplugMqttCallback implements MqttCallback { private final List messageArrivedMetrics = new ArrayList<>(); @Override diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java index 4ca7ea5661..e4e76e6761 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java @@ -16,7 +16,6 @@ package org.thingsboard.server.transport.mqtt.sparkplug.attributes; import com.fasterxml.jackson.core.type.TypeReference; -import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.junit.Assert; import org.thingsboard.server.common.data.Device; @@ -33,8 +32,10 @@ import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType.UInt32; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DCMD; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DDATA; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NCMD; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NDATA; /** * Created by nickAS21 on 12.01.23 @@ -50,7 +51,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Assert.assertTrue(listKeys.contains(keyNodeRebirth)); String SHARED_ATTRIBUTES_PAYLOAD = "{\"" + keyNodeRebirth + "\":" + value + "}"; Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); doPostAsync("/api/plugins/telemetry/DEVICE/" + savedGateway.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); await(alias + SparkplugMessageType.NBIRTH.name()) @@ -76,7 +76,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextBoolean(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Boolean <-> String @@ -140,7 +139,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextUInt32(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Long <-> String @@ -192,7 +190,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextFloat(30, 400); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Float <-> String @@ -237,6 +234,67 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Assert.assertTrue(expectedValue == mqttCallback.getMessageArrivedMetrics().get(0).getFloatValue()); } + protected void processClientWithCorrectAccessTokenPublishMetricDataTypeFromJson_SendValueOk() throws Exception { + long ts = calendar.getTimeInMillis(); + List devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(1, ts); + awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); + awaitForDeviceActorToReceiveSubscription(devices.get(0).getId(), FeatureType.ATTRIBUTES, 1); + + // Node Edge + SparkplugMessageType messageType = NCMD; + String keyBirthNameNode = "Node Metric int32"; + int valueBirthNameNode = 1024; + String SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameNode + "\":" + valueBirthNameNode + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + savedGateway.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + NCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameNode, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameNode == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + + messageType = NDATA; + SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameNode + "\":" + valueBirthNameNode + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + savedGateway.getId().getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + NCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameNode, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameNode == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + + // Device + messageType = DCMD; + String keyBirthNameDevice = metricBirthName_Int32; + int valueBirthNameDevice = 123456; + SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameDevice + "\":" + valueBirthNameDevice + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + devices.get(0).getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + DCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameDevice, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameDevice == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + + messageType = DDATA; + SHARED_ATTRIBUTES_PAYLOAD = "{\"" + messageType.name() + "\": {\"" + keyBirthNameDevice + "\":" + valueBirthNameDevice + "}}"; + doPostAsync("/api/plugins/telemetry/DEVICE/" + devices.get(0).getId() + "/attributes/SHARED_SCOPE", SHARED_ATTRIBUTES_PAYLOAD, String.class, status().isOk()); + await(alias + DCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(keyBirthNameDevice, mqttCallback.getMessageArrivedMetrics().get(0).getName()); + Assert.assertTrue(valueBirthNameDevice == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + mqttCallback.deleteMessageArrivedMetrics(0); + } + protected void processClientWithCorrectAccessTokenPublishNCMD_DoubleType_IfMetricFailedTypeCheck_SendValueOk() throws Exception { clientWithCorrectNodeAccessTokenWithNDEATH(); MetricDataType metricDataType = MetricDataType.Double; @@ -244,7 +302,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextDouble(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // Double <-> String @@ -296,7 +353,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra Object metricValue = nextString(); connectionWithNBirth(metricDataType, metricKey, metricValue); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.ATTRIBUTES, 1); // String <-> Long @@ -356,7 +412,6 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra return mqttCallback.getMessageArrivedMetrics().size() == 1; }); Assert.assertEquals(metricBirthName_Int32, mqttCallback.getMessageArrivedMetrics().get(0).getName()); - Assert.assertEquals(metricBirthName_Int32, mqttCallback.getMessageArrivedMetrics().get(0).getName()); Assert.assertEquals(expectedValueInt, mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java index a8d67360be..cecad77c7a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/MqttV5ClientSparkplugBAttributesTest.java @@ -57,6 +57,10 @@ public class MqttV5ClientSparkplugBAttributesTest extends AbstractMqttV5ClientSp public void testClientWithCorrectAccessTokenPublishNCMD_FloatType_IfMetricFailedTypeCheck_SendValueOk() throws Exception { processClientWithCorrectAccessTokenPublishNCMD_FloatType_IfMetricFailedTypeCheck_SendValueOk(); } + @Test + public void testClientWithCorrectAccessTokenPublishMetricDataTypeFromJson_SendValueOk() throws Exception { + processClientWithCorrectAccessTokenPublishMetricDataTypeFromJson_SendValueOk(); + } @Test public void testClientWithCorrectAccessTokenPublishNCMD_DoubleType_IfMetricFailedTypeCheck_SendValueOk() throws Exception { diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java index 459596e38f..9b22bd52d9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/connection/AbstractMqttV5ClientSparkplugConnectionTest.java @@ -40,7 +40,7 @@ import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConn import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.messageName; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; /** * Created by nickAS21 on 12.01.23 @@ -80,7 +80,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra protected void processClientWithCorrectNodeAccessTokenNameSpaceInvalid_Test() throws Exception { long ts = calendar.getTimeInMillis() - PUBLISH_TS_DELTA_MS; long value = bdSeq = 0; - MqttException actualException = Assert.assertThrows(MqttException.class, () -> clientConnectWithNDEATH(ts, value, "spBv1.2")); + MqttException actualException = Assert.assertThrows(MqttException.class, () -> clientMqttV5ConnectWithNDEATH(ts, value, -1L,"spBv1.2")); String expectedMessage = "Server unavailable."; int expectedReasonCode = 136; Assert.assertEquals(expectedMessage, actualException.getMessage()); @@ -135,7 +135,7 @@ public abstract class AbstractMqttV5ClientSparkplugConnectionTest extends Abstra if (client.isConnected()) { List devicesList = new ArrayList<>(devices); Device device = devicesList.get(indexDeviceDisconnect); - client.publish(NAMESPACE + "/" + groupId + "/" + SparkplugMessageType.DDEATH.name() + "/" + edgeNode + "/" + device.getName(), + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DDEATH.name() + "/" + edgeNode + "/" + device.getName(), payloadDeathDevice.build().toByteArray(), 0, false); await(alias + messageName(STATE) + ", device: " + device.getName()) .atMost(40, TimeUnit.SECONDS) diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java index 8d351bbd9d..96fc9c0a4c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/AbstractMqttV5RpcSparkplugTest.java @@ -15,8 +15,8 @@ */ package org.thingsboard.server.transport.mqtt.sparkplug.rpc; -import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; +import org.eclipse.paho.mqttv5.common.MqttException; import org.junit.Assert; import org.junit.Test; import org.thingsboard.server.common.data.Device; @@ -32,7 +32,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import static org.thingsboard.server.common.data.exception.ThingsboardErrorCode.INVALID_ARGUMENTS; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DCMD; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NCMD; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; @Slf4j public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5ClientSparkplugTest { @@ -45,7 +44,6 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie clientWithCorrectNodeAccessTokenWithNDEATH(); connectionWithNBirth(metricBirthDataType_Int32, metricBirthName_Int32, nextInt32()); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.RPC, 1); String expected = "{\"result\":\"Success: " + SparkplugMessageType.NCMD.name() + "\"}"; String actual = sendRPCSparkplug(NCMD.name(), sparkplugRpcRequest, savedGateway); @@ -63,9 +61,10 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie public void processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception { long ts = calendar.getTimeInMillis(); List devices = connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(1, ts); + awaitForDeviceActorToReceiveSubscription(devices.get(0).getId(), FeatureType.RPC, 1); String expected = "{\"result\":\"Success: " + DCMD.name() + "\"}"; String actual = sendRPCSparkplug(DCMD.name() , sparkplugRpcRequest, devices.get(0)); - await(alias + NCMD.name()) + await(alias + DCMD.name()) .atMost(40, TimeUnit.SECONDS) .until(() -> { return mqttCallback.getMessageArrivedMetrics().size() == 1; @@ -75,12 +74,41 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie Assert.assertTrue(metricBirthValue_Int32 == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); } + @Test + public void processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception { + long ts = calendar.getTimeInMillis(); + List devices = connectClientWithCorrectAccessTokenWithNDEATHWithAliasCreatedDevices(ts); + awaitForDeviceActorToReceiveSubscription(devices.get(0).getId(), FeatureType.RPC, 1); + String expected = "{\"result\":\"Success: " + DCMD.name() + "\"}"; + String actual = sendRPCSparkplug(DCMD.name() , sparkplugRpcRequest, devices.get(0)); + await(alias + DCMD.name()) + .atMost(40, TimeUnit.SECONDS) + .until(() -> { + return mqttCallback.getMessageArrivedMetrics().size() == 1; + }); + Assert.assertEquals(expected, actual); + Assert.assertFalse(mqttCallback.getMessageArrivedMetrics().get(0).hasName()); + Assert.assertTrue(mqttCallback.getMessageArrivedMetrics().get(0).hasAlias()); + Assert.assertTrue(2L == mqttCallback.getMessageArrivedMetrics().get(0).getAlias()); + Assert.assertTrue(metricBirthValue_Int32 == mqttCallback.getMessageArrivedMetrics().get(0).getIntValue()); + } + + @Test + public void processClientNodeWithCorrectAccessTokenPublishWithAliasWithoutMetricName_TwoWayRpc_BAD_REQUEST_PARAMS() throws Exception { + long ts = calendar.getTimeInMillis() - PUBLISH_TS_DELTA_MS; + long value = bdSeq = 0; + MqttException actualException = Assert.assertThrows(MqttException.class, () -> clientMqttV5ConnectWithNDEATH(ts, value, "",4L)); + String expectedMessage = "Server unavailable."; + int expectedReasonCode = 136; + Assert.assertEquals(expectedMessage, actualException.getMessage()); + Assert.assertEquals(expectedReasonCode, actualException.getReasonCode()); + } + @Test public void processClientNodeWithCorrectAccessTokenPublish_TwoWayRpc_InvalidTypeMessage_INVALID_ARGUMENTS() throws Exception { clientWithCorrectNodeAccessTokenWithNDEATH(); connectionWithNBirth(metricBirthDataType_Int32, metricBirthName_Int32, nextInt32()); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.RPC, 1); String invalidateTypeMessageName = "RCMD"; String expected = "{\"result\":\"" + INVALID_ARGUMENTS + "\",\"error\":\"Failed to convert device RPC command to MQTT msg: " + @@ -94,7 +122,6 @@ public abstract class AbstractMqttV5RpcSparkplugTest extends AbstractMqttV5Clie clientWithCorrectNodeAccessTokenWithNDEATH(); connectionWithNBirth(metricBirthDataType_Int32, metricBirthName_Int32, nextInt32()); Assert.assertTrue("Connection node is failed", client.isConnected()); - client.subscribeAndWait(NAMESPACE + "/" + groupId + "/" + NCMD.name() + "/" + edgeNode + "/#", MqttQoS.AT_MOST_ONCE); awaitForDeviceActorToReceiveSubscription(savedGateway.getId(), FeatureType.RPC, 1); String metricNameBad = metricBirthName_Int32 + "_Bad"; String sparkplugRpcRequestBad = "{\"metricName\":\"" + metricNameBad + "\",\"value\":" + metricBirthValue_Int32 + "}"; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java index 10ef53260c..eb6c613940 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/rpc/MqttV5RpcSparkplugTest.java @@ -47,6 +47,15 @@ public class MqttV5RpcSparkplugTest extends AbstractMqttV5RpcSparkplugTest { public void testClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success() throws Exception { processClientDeviceWithCorrectAccessTokenPublish_TwoWayRpc_Success(); } + @Test + public void testClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success() throws Exception { + processClientDeviceWithCorrectAccessTokenPublishWithAlias_TwoWayRpc_Success(); + } + + @Test + public void testClientNodeWithCorrectAccessTokenPublishWithAliasWithoutMetricName_TwoWayRpc_BAD_REQUEST_PARAMS() throws Exception { + processClientNodeWithCorrectAccessTokenPublishWithAliasWithoutMetricName_TwoWayRpc_BAD_REQUEST_PARAMS(); + } @Test public void testClientNodeWithCorrectAccessTokenPublish_TwoWayRpc_InvalidTypeMessage_INVALID_ARGUMENTS() throws Exception { diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java index c5d0dec98b..8368db546b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/timeseries/AbstractMqttV5ClientSparkplugTelemetryTest.java @@ -29,7 +29,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.awaitility.Awaitility.await; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.NAMESPACE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; /** * Created by nickAS21 on 12.01.23 @@ -67,7 +67,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac createdAddMetricValuePrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, ndataPayload.build().toByteArray(), 0, false); } @@ -96,7 +96,7 @@ public abstract class AbstractMqttV5ClientSparkplugTelemetryTest extends Abstrac createdAddMetricValueArraysPrimitiveTsKv(listTsKvEntry, listKeys, ndataPayload, ts); if (client.isConnected()) { - client.publish(NAMESPACE + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, + client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + messageTypeName + "/" + edgeNode, ndataPayload.build().toByteArray(), 0, false); } diff --git a/common/actor/pom.xml b/common/actor/pom.xml index 084ef734fa..a6efdda77d 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 88eadb17cc..3088dad098 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index c455dfa337..939dfb1e16 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java index bb5bc1d668..523a97e13c 100644 --- a/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java +++ b/common/cluster-api/src/main/java/org/thingsboard/server/queue/TbQueueHandler.java @@ -17,11 +17,12 @@ package org.thingsboard.server.queue; import com.google.common.util.concurrent.ListenableFuture; -/** - * Created by ashvayka on 05.10.18. - */ public interface TbQueueHandler { ListenableFuture handle(Request request); + default Response constructErrorResponseMsg(Request request, Throwable cause) { + return null; + } + } diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index 99ad0f0b56..fbfbe78b55 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java index 98df973c65..5093a5025e 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapServerComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.coapserver; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${coap.server.enabled}'=='true')") -public @interface TbCoapServerComponent { -} +public @interface TbCoapServerComponent {} diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java index 558ccf16ef..a68810f10c 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapTransportComponent.java @@ -17,11 +17,12 @@ package org.thingsboard.server.coapserver; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || " + "('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${coap.server.enabled}'=='true' && '${transport.coap.enabled}'=='true')") -public @interface TbCoapTransportComponent { -} +public @interface TbCoapTransportComponent {} diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index cbf928ac73..636c7b9ef4 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.java new file mode 100644 index 0000000000..3ad12048cf --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiModelService.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.server.dao.ai; + +import com.google.common.util.concurrent.FluentFuture; +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.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.EntityDaoService; + +import java.util.Optional; + +public interface AiModelService extends EntityDaoService { + + AiModel save(AiModel model); + + Optional findAiModelById(TenantId tenantId, AiModelId modelId); + + PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink); + + Optional findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId); + + FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId); + + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java index 7be61d15d5..e26955d465 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/alarm/AlarmService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.alarm; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.alarm.Alarm; @@ -105,6 +106,8 @@ public interface AlarmService extends EntityDaoService { Alarm findLatestActiveByOriginatorAndType(TenantId tenantId, EntityId originator, String type); + FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); + PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java index 12c8e5053b..b0cdb4d384 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ota/OtaPackageService.java @@ -41,6 +41,8 @@ public interface OtaPackageService extends EntityDaoService { OtaPackageInfo findOtaPackageInfoById(TenantId tenantId, OtaPackageId otaPackageId); + OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version); + ListenableFuture findOtaPackageInfoByIdAsync(TenantId tenantId, OtaPackageId otaPackageId); PageData findTenantOtaPackagesByTenantId(TenantId tenantId, PageLink pageLink); @@ -52,4 +54,5 @@ public interface OtaPackageService extends EntityDaoService { void deleteOtaPackagesByTenantId(TenantId tenantId); long sumDataSizeByTenantId(TenantId tenantId); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java index b803700582..5223a2a9c9 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java @@ -18,8 +18,9 @@ package org.thingsboard.server.dao.settings; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.AdminSettingsId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.entity.EntityDaoService; -public interface AdminSettingsService { +public interface AdminSettingsService extends EntityDaoService { AdminSettings findAdminSettingsById(TenantId tenantId, AdminSettingsId adminSettingsId); @@ -31,6 +32,4 @@ public interface AdminSettingsService { boolean deleteAdminSettingsByTenantIdAndKey(TenantId tenantId, String key); - void deleteAdminSettingsByTenantId(TenantId tenantId); - } diff --git a/common/data/pom.xml b/common/data/pom.xml index fd0c38c3e7..da2a0970b1 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common @@ -112,6 +112,10 @@ leshan-core compile + + dev.langchain4j + langchain4j-core + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java b/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java index 2ac78b04b6..614cf67054 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/BaseData.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.id.IdBased; import org.thingsboard.server.common.data.id.UUIDBased; @@ -41,6 +42,11 @@ public abstract class BaseData extends IdBased implement this.createdTime = data.getCreatedTime(); } + @Schema( + description = "Entity creation timestamp in milliseconds since Unix epoch", + example = "1746028547220", + accessMode = Schema.AccessMode.READ_ONLY + ) public long getCreatedTime() { return createdTime; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 5b167c88a2..b55453f393 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -15,7 +15,10 @@ */ package org.thingsboard.server.common.data; -public class CacheConstants { +public final class CacheConstants { + + private CacheConstants() {} + public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials"; public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; @@ -36,6 +39,7 @@ public class CacheConstants { public static final String NOTIFICATION_SETTINGS_CACHE = "notificationSettings"; public static final String SENT_NOTIFICATIONS_CACHE = "sentNotifications"; public static final String TRENDZ_SETTINGS_CACHE = "trendzSettings"; + public static final String AI_MODEL_CACHE = "aiModel"; public static final String ASSET_PROFILE_CACHE = "assetProfiles"; public static final String ATTRIBUTES_CACHE = "attributes"; @@ -54,4 +58,5 @@ public class CacheConstants { public static final String ALARM_TYPES_CACHE = "alarmTypes"; public static final String QR_CODE_SETTINGS_CACHE = "qrCodeSettings"; public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey"; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index b2d9d59cca..8a72b26a28 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.data; -/** - * @author Andrew Shvayka - */ public class DataConstants { public static final String TENANT = "TENANT"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index af5fec1827..110052b57f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -22,9 +22,6 @@ import java.util.Arrays; import java.util.EnumSet; import java.util.List; -/** - * @author Andrew Shvayka - */ public enum EntityType { TENANT(1), CUSTOMER(2), @@ -65,7 +62,14 @@ public enum EntityType { MOBILE_APP_BUNDLE(38), CALCULATED_FIELD(39), CALCULATED_FIELD_LINK(40), - JOB(41); + JOB(41), + ADMIN_SETTINGS(42), + AI_MODEL(43, "ai_model") { + @Override + public String getNormalName() { + return "AI model"; + } + }; @Getter private final int protoNumber; // Corresponds to EntityTypeProto diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackage.java b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackage.java index 26376bbda3..abcccdfa9a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackage.java @@ -20,6 +20,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.OtaPackageId; +import java.io.Serial; import java.nio.ByteBuffer; @Schema @@ -27,6 +28,7 @@ import java.nio.ByteBuffer; @EqualsAndHashCode(callSuper = true) public class OtaPackage extends OtaPackageInfo { + @Serial private static final long serialVersionUID = 3091601761339422546L; @Schema(description = "OTA Package data.", accessMode = Schema.AccessMode.READ_ONLY) @@ -44,4 +46,10 @@ public class OtaPackage extends OtaPackageInfo { super(otaPackage); this.data = otaPackage.getData(); } + + public OtaPackage(OtaPackageInfo otaPackageInfo) { + super(otaPackageInfo); + this.data = null; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java index dfdbd83e80..9f010db823 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -29,12 +30,15 @@ import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +import java.io.Serial; + @Schema @Slf4j @Data @EqualsAndHashCode(callSuper = true) -public class OtaPackageInfo extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, HasTitle { +public class OtaPackageInfo extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, HasTitle, ExportableEntity { + @Serial private static final long serialVersionUID = 3168391583570815419L; @Schema(description = "JSON object with Tenant Id. Tenant Id of the ota package can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) @@ -77,6 +81,8 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp @Schema(description = "OTA Package data size.", example = "8", accessMode = Schema.AccessMode.READ_ONLY) private Long dataSize; + private OtaPackageId externalId; + public OtaPackageInfo() { super(); } @@ -100,6 +106,7 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp this.checksumAlgorithm = otaPackageInfo.getChecksumAlgorithm(); this.checksum = otaPackageInfo.getChecksum(); this.dataSize = otaPackageInfo.getDataSize(); + this.externalId = otaPackageInfo.getExternalId(); } @Schema(description = "JSON object with the ota package Id. " + @@ -118,7 +125,7 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp } @Override - @JsonIgnore + @JsonProperty(access = JsonProperty.Access.READ_ONLY) public String getName() { return title; } @@ -133,4 +140,5 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index 498fa5be3e..ba37067106 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -23,6 +23,7 @@ import lombok.EqualsAndHashCode; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.TbResourceId; +import java.io.Serial; import java.util.Base64; import java.util.Optional; @@ -31,6 +32,7 @@ import java.util.Optional; @EqualsAndHashCode(callSuper = true) public class TbResource extends TbResourceInfo { + @Serial private static final long serialVersionUID = 7379609705527272306L; private byte[] data; @@ -88,4 +90,5 @@ public class TbResource extends TbResourceInfo { public String toString() { return super.toString(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java new file mode 100644 index 0000000000..4d7bb21930 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java @@ -0,0 +1,102 @@ +/** + * 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.ai; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoNullChar; + +import java.io.Serial; + +@Data +@Builder +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public final class AiModel extends BaseData implements HasTenantId, HasVersion, ExportableEntity { + + @Serial + private static final long serialVersionUID = 9017108678716011604L; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, + description = "JSON object representing the ID of the tenant associated with this AI model", + example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" + ) + private TenantId tenantId; + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, + description = "Version of the AI model record; increments automatically whenever the record is changed", + example = "7", + defaultValue = "1" + ) + private Long version; + + @NotBlank + @NoNullChar + @Length(min = 1, max = 255) + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Display name for this AI model configuration; not the technical model identifier", + example = "Fast and cost-efficient model" + ) + private String name; + + @NotNull + @Valid + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Configuration of the AI model" + ) + private AiModelConfig configuration; + + private AiModelId externalId; + + public AiModel() {} + + public AiModel(AiModelId id) { + super(id); + } + + public AiModel(AiModel model) { + super(model.getId()); + createdTime = model.getCreatedTime(); + tenantId = model.getTenantId(); + version = model.getVersion(); + name = model.getName(); + configuration = model.getConfiguration(); + externalId = model.getExternalId() == null ? null : new AiModelId(model.getExternalId().getId()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java new file mode 100644 index 0000000000..7e43520b79 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatRequest.java @@ -0,0 +1,79 @@ +/** + * 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.ai.dto; + +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; + +import java.util.ArrayList; +import java.util.List; + +public record TbChatRequest( + @Schema( + requiredMode = Schema.RequiredMode.NOT_REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "A system-level instruction that frames the user's input, setting the persona, tone, and constraints for the generated response", + example = "You are a helpful assistant. Only output valid JSON." + ) + String systemMessage, + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "The actual user prompt that will be answered by the AI model" + ) + @NotNull @Valid + TbUserMessage userMessage, + + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_WRITE, + description = "Configuration of the AI chat model that should execute the request" + ) + @NotNull @Valid + AiChatModelConfig chatModelConfig +) { + + public ChatRequest toLangChainChatRequest() { + return ChatRequest.builder() + .messages(getLangChainMessages()) + .build(); + } + + private List getLangChainMessages() { + List messages = new ArrayList<>(2); + + if (systemMessage != null) { + messages.add(SystemMessage.from(systemMessage)); + } + + List langChainContents = userMessage.contents().stream() + .map(TbContent::toLangChainContent) + .toList(); + + messages.add(UserMessage.from(langChainContents)); + + return messages; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java new file mode 100644 index 0000000000..2cc17e4553 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "status", + include = JsonTypeInfo.As.PROPERTY, + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbChatResponse.Success.class, name = "SUCCESS"), + @JsonSubTypes.Type(value = TbChatResponse.Failure.class, name = "FAILURE") +}) +public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatResponse.Failure { + + @Schema( + description = "Indicates whether the request was successful or not", + example = "SUCCESS" + ) + String getStatus(); + + record Success( + @Schema(description = "The text content generated by the model") + String generatedContent + ) implements TbChatResponse { + + @Override + @Schema(example = "SUCCESS") + public String getStatus() { + return "SUCCESS"; + } + + } + + record Failure( + @Schema( + description = "A string containing details about the failure" + ) + String errorDetails + ) implements TbChatResponse { + + @Override + @Schema(example = "FAILURE") + public String getStatus() { + return "FAILURE"; + } + + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java new file mode 100644 index 0000000000..23543121a4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java @@ -0,0 +1,73 @@ +/** + * 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.ai.dto; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import dev.langchain4j.data.message.Content; +import dev.langchain4j.data.message.TextContent; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +import static org.thingsboard.server.common.data.ai.dto.TbContent.TbTextContent; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "contentType", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbTextContent.class, name = "TEXT") +}) +public sealed interface TbContent permits TbTextContent { + + TbContentType contentType(); + + Content toLangChainContent(); + + enum TbContentType { + + TEXT + + } + + @Schema( + description = "Text-based content part of a user's prompt" + ) + record TbTextContent( + @NotBlank + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "The text content", + example = "What is the weather like in Kyiv today?" + ) + String text + ) implements TbContent { + + @Override + public TbContentType contentType() { + return TbContentType.TEXT; + } + + @Override + public Content toLangChainContent() { + return TextContent.from(text); + } + + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java new file mode 100644 index 0000000000..fdd7d8dc63 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java @@ -0,0 +1,32 @@ +/** + * 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.ai.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record TbUserMessage( + @NotEmpty + @Valid + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "A list of content parts that make up the complete user prompt" + ) + List contents +) {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java new file mode 100644 index 0000000000..0a2b41a91f --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -0,0 +1,78 @@ +/** + * 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.ai.model; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GitHubModelsChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.MistralAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "provider", + visible = true +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiChatModelConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiChatModelConfig.class, name = "AZURE_OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiChatModelConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiChatModelConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), + @JsonSubTypes.Type(value = MistralAiChatModelConfig.class, name = "MISTRAL_AI"), + @JsonSubTypes.Type(value = AnthropicChatModelConfig.class, name = "ANTHROPIC"), + @JsonSubTypes.Type(value = AmazonBedrockChatModelConfig.class, name = "AMAZON_BEDROCK"), + @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS") +}) +public interface AiModelConfig { + + AiProvider provider(); + + @JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.EXTERNAL_PROPERTY, + property = "provider" + ) + @JsonSubTypes({ + @JsonSubTypes.Type(value = OpenAiProviderConfig.class, name = "OPENAI"), + @JsonSubTypes.Type(value = AzureOpenAiProviderConfig.class, name = "AZURE_OPENAI"), + @JsonSubTypes.Type(value = GoogleAiGeminiProviderConfig.class, name = "GOOGLE_AI_GEMINI"), + @JsonSubTypes.Type(value = GoogleVertexAiGeminiProviderConfig.class, name = "GOOGLE_VERTEX_AI_GEMINI"), + @JsonSubTypes.Type(value = MistralAiProviderConfig.class, name = "MISTRAL_AI"), + @JsonSubTypes.Type(value = AnthropicProviderConfig.class, name = "ANTHROPIC"), + @JsonSubTypes.Type(value = AmazonBedrockProviderConfig.class, name = "AMAZON_BEDROCK"), + @JsonSubTypes.Type(value = GitHubModelsProviderConfig.class, name = "GITHUB_MODELS") + }) + AiProviderConfig providerConfig(); + + AiModelType modelType(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java new file mode 100644 index 0000000000..d6299cf5e6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelType.java @@ -0,0 +1,22 @@ +/** + * 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.ai.model; + +public enum AiModelType { + + CHAT + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java new file mode 100644 index 0000000000..2bc28cfce0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.langchain4j.model.chat.ChatModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.AiModelType; + +public sealed interface AiChatModelConfig> extends AiModelConfig + permits + OpenAiChatModelConfig, AzureOpenAiChatModelConfig, GoogleAiGeminiChatModelConfig, + GoogleVertexAiGeminiChatModelConfig, MistralAiChatModelConfig, AnthropicChatModelConfig, + AmazonBedrockChatModelConfig, GitHubModelsChatModelConfig { + + ChatModel configure(Langchain4jChatModelConfigurer configurer); + + @Override + @JsonProperty(value = "modelType", access = JsonProperty.Access.READ_ONLY) + default AiModelType modelType() { + return AiModelType.CHAT; + } + + Integer timeoutSeconds(); + + Integer maxRetries(); + + C withTimeoutSeconds(Integer timeoutSeconds); + + C withMaxRetries(Integer maxRetries); + + boolean supportsJsonMode(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java new file mode 100644 index 0000000000..2bb4de5aa8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -0,0 +1,56 @@ +/** + * 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; + +@Builder +public record AmazonBedrockChatModelConfig( + @NotNull @Valid AmazonBedrockProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.AMAZON_BEDROCK; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java new file mode 100644 index 0000000000..69b5578fb3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; + +@Builder +public record AnthropicChatModelConfig( + @NotNull @Valid AnthropicProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.ANTHROPIC; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java new file mode 100644 index 0000000000..47e7e96c37 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; + +@Builder +public record AzureOpenAiChatModelConfig( + @NotNull @Valid AzureOpenAiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.AZURE_OPENAI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java new file mode 100644 index 0000000000..b509254f77 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; + +@Builder +public record GitHubModelsChatModelConfig( + @NotNull @Valid GitHubModelsProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.GITHUB_MODELS; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return false; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java new file mode 100644 index 0000000000..fe11a11460 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -0,0 +1,59 @@ +/** + * 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; + +@Builder +public record GoogleAiGeminiChatModelConfig( + @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.GOOGLE_AI_GEMINI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java new file mode 100644 index 0000000000..609e14f86e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -0,0 +1,59 @@ +/** + * 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.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; + +@Builder +public record GoogleVertexAiGeminiChatModelConfig( + @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + @PositiveOrZero Integer topK, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.GOOGLE_VERTEX_AI_GEMINI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java new file mode 100644 index 0000000000..c9c1bc3173 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/Langchain4jChatModelConfigurer.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; + +public interface Langchain4jChatModelConfigurer { + + ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(GoogleAiGeminiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(GoogleVertexAiGeminiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(MistralAiChatModelConfig chatModelConfig); + + ChatModel configureChatModel(AnthropicChatModelConfig chatModelConfig); + + ChatModel configureChatModel(AmazonBedrockChatModelConfig chatModelConfig); + + ChatModel configureChatModel(GitHubModelsChatModelConfig chatModelConfig); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java new file mode 100644 index 0000000000..f603e99c53 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; + +@Builder +public record MistralAiChatModelConfig( + @NotNull @Valid MistralAiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.MISTRAL_AI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java new file mode 100644 index 0000000000..00b5115d7d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.model.chat; + +import dev.langchain4j.model.chat.ChatModel; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.Builder; +import lombok.With; +import org.thingsboard.server.common.data.ai.provider.AiProvider; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +@Builder +public record OpenAiChatModelConfig( + @NotNull @Valid OpenAiProviderConfig providerConfig, + @NotBlank String modelId, + @PositiveOrZero Double temperature, + @Positive @Max(1) Double topP, + Double frequencyPenalty, + Double presencePenalty, + @Positive Integer maxOutputTokens, + @With @Positive Integer timeoutSeconds, + @With @PositiveOrZero Integer maxRetries +) implements AiChatModelConfig { + + @Override + public AiProvider provider() { + return AiProvider.OPENAI; + } + + @Override + public ChatModel configure(Langchain4jChatModelConfigurer configurer) { + return configurer.configureChatModel(this); + } + + @Override + public boolean supportsJsonMode() { + return true; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java new file mode 100644 index 0000000000..d0a5bd0510 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProvider.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.provider; + +public enum AiProvider { + + OPENAI, + AZURE_OPENAI, + GOOGLE_AI_GEMINI, + GOOGLE_VERTEX_AI_GEMINI, + MISTRAL_AI, + ANTHROPIC, + AMAZON_BEDROCK, + GITHUB_MODELS + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java new file mode 100644 index 0000000000..bd32c88efb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.ai.provider; + +public sealed interface AiProviderConfig + permits + OpenAiProviderConfig, AzureOpenAiProviderConfig, GoogleAiGeminiProviderConfig, + GoogleVertexAiGeminiProviderConfig, MistralAiProviderConfig, AnthropicProviderConfig, + AmazonBedrockProviderConfig, GitHubModelsProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java new file mode 100644 index 0000000000..e705b545c2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record AmazonBedrockProviderConfig( + @NotNull String region, + @NotNull String accessKeyId, + @NotNull String secretAccessKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java new file mode 100644 index 0000000000..d6db07941b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record AnthropicProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java new file mode 100644 index 0000000000..ea7ffebe3a --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record AzureOpenAiProviderConfig( + @NotNull String endpoint, + String serviceVersion, + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java new file mode 100644 index 0000000000..f5240fe836 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record GitHubModelsProviderConfig( + @NotNull String personalAccessToken +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java new file mode 100644 index 0000000000..bfa729e66d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record GoogleAiGeminiProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java new file mode 100644 index 0000000000..b0efac764c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java @@ -0,0 +1,26 @@ +/** + * 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.ai.provider; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record GoogleVertexAiGeminiProviderConfig( + @NotBlank String fileName, // not used on BE, but needed for UI + @NotNull String projectId, + @NotNull String location, + @NotNull String serviceAccountKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java new file mode 100644 index 0000000000..eb62557a15 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record MistralAiProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java new file mode 100644 index 0000000000..09ffda837b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -0,0 +1,22 @@ +/** + * 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.ai.provider; + +import jakarta.validation.constraints.NotNull; + +public record OpenAiProviderConfig( + @NotNull String apiKey +) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java index a48a865fe4..519b30a356 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java @@ -37,13 +37,11 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +import java.io.Serial; import java.util.List; import java.util.Optional; import java.util.UUID; -/** - * Created by ashvayka on 11.05.17. - */ @Schema @Data @EqualsAndHashCode(callSuper = true) @@ -52,6 +50,9 @@ import java.util.UUID; @JsonIgnoreProperties(ignoreUnknown = true) public class Alarm extends BaseData implements HasName, HasTenantId, HasCustomerId { + @Serial + private static final long serialVersionUID = -1935800187424953611L; + @Schema(description = "JSON object with Tenant Id", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java index 972887e702..0654ba5a9d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java @@ -30,11 +30,17 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +import java.io.Serial; + @Schema @Data @Builder @AllArgsConstructor public class AlarmComment extends BaseData implements HasName { + + @Serial + private static final long serialVersionUID = -5454905526404017592L; + @Schema(description = "JSON object with Alarm id.", accessMode = Schema.AccessMode.READ_ONLY) private AlarmId alarmId; @Schema(description = "JSON object with User id.", accessMode = Schema.AccessMode.READ_ONLY) @@ -85,4 +91,5 @@ public class AlarmComment extends BaseData implements HasName { this.comment = alarmComment.getComment(); this.userId = alarmComment.getUserId(); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java index 83df975ad4..b2d01c8f52 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java @@ -22,9 +22,6 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.TimePageLink; -/** - * Created by ashvayka on 11.05.17. - */ @Data @Builder @AllArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java index 18e989c1bd..1eaa2ea393 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmSeverity.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.data.alarm; -/** - * Created by ashvayka on 11.05.17. - */ public enum AlarmSeverity { CRITICAL, MAJOR, MINOR, WARNING, INDETERMINATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmStatus.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmStatus.java index 9a69ae8e23..c97b0d24f2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmStatus.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmStatus.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.data.alarm; -/** - * Created by ashvayka on 11.05.17. - */ public enum AlarmStatus { ACTIVE_UNACK, ACTIVE_ACK, CLEARED_UNACK, CLEARED_ACK; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java index 8f2104b5fd..05194b8df5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java @@ -17,14 +17,26 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; -public class AdminSettingsId extends UUIDBased { +public class AdminSettingsId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = -4208011957475806567L; @JsonCreator - public AdminSettingsId(@JsonProperty("id") UUID id){ + public AdminSettingsId(@JsonProperty("id") UUID id) { super(id); } - + + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java new file mode 100644 index 0000000000..cac9e8200c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; + +import java.io.Serial; +import java.util.UUID; + +public final class AiModelId extends UUIDBased implements EntityId { + + @Serial + private static final long serialVersionUID = 3021036138554389754L; + + @JsonCreator + public AiModelId(@JsonProperty("id") UUID id) { + super(id); + } + + @Override + @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "Entity type of the AI model", + example = "AI_MODEL", + allowableValues = "AI_MODEL" + ) + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + + public static AiModelId fromString(String uuid) { + return new AiModelId(UUID.fromString(uuid)); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java index 9a20be4eb9..4ccabd39b8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java @@ -23,10 +23,12 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class EdgeId extends UUIDBased implements EntityId { + @Serial private static final long serialVersionUID = 1L; @JsonIgnore @@ -51,4 +53,5 @@ public class EdgeId extends UUIDBased implements EntityId { public static EdgeId fromUUID(@JsonProperty("id") UUID id) { return edges.computeIfAbsent(id, EdgeId::new); } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java index 24196e28b5..ddfcd85dc8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java @@ -24,10 +24,6 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serializable; import java.util.UUID; -/** - * @author Andrew Shvayka - */ - @JsonDeserialize(using = EntityIdDeserializer.class) @JsonSerialize(using = EntityIdSerializer.class) @Schema diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index d23e4c078d..fc1c1cd1a9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -20,9 +20,6 @@ import org.thingsboard.server.common.data.edge.EdgeEventType; import java.util.UUID; -/** - * Created by ashvayka on 25.04.17. - */ public class EntityIdFactory { public static EntityId getByTypeAndUuid(int type, String uuid) { @@ -50,131 +47,74 @@ public class EntityIdFactory { } public static EntityId getByTypeAndUuid(EntityType type, UUID uuid) { - switch (type) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case RULE_NODE: - return new RuleNodeId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case API_USAGE_STATE: - return new ApiUsageStateId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case RPC: - return new RpcId(uuid); - case QUEUE: - return new QueueId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_REQUEST: - return new NotificationRequestId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case NOTIFICATION: - return new NotificationId(uuid); - case QUEUE_STATS: - return new QueueStatsId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case MOBILE_APP: - return new MobileAppId(uuid); - case DOMAIN: - return new DomainId(uuid); - case MOBILE_APP_BUNDLE: - return new MobileAppBundleId(uuid); - case CALCULATED_FIELD: - return new CalculatedFieldId(uuid); - case CALCULATED_FIELD_LINK: - return new CalculatedFieldLinkId(uuid); - case JOB: - return new JobId(uuid); - } - throw new IllegalArgumentException("EntityType " + type + " is not supported!"); + return switch (type) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case RULE_NODE -> new RuleNodeId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case API_USAGE_STATE -> new ApiUsageStateId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> new EdgeId(uuid); + case RPC -> new RpcId(uuid); + case QUEUE -> new QueueId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_REQUEST -> new NotificationRequestId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case NOTIFICATION -> new NotificationId(uuid); + case QUEUE_STATS -> new QueueStatsId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case MOBILE_APP -> new MobileAppId(uuid); + case DOMAIN -> new DomainId(uuid); + case MOBILE_APP_BUNDLE -> new MobileAppBundleId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + case CALCULATED_FIELD_LINK -> new CalculatedFieldLinkId(uuid); + case JOB -> new JobId(uuid); + case ADMIN_SETTINGS -> new AdminSettingsId(uuid); + case AI_MODEL -> new AiModelId(uuid); + }; } public static EntityId getByEdgeEventTypeAndUuid(EdgeEventType edgeEventType, UUID uuid) { - switch (edgeEventType) { - case TENANT: - return TenantId.fromUUID(uuid); - case CUSTOMER: - return new CustomerId(uuid); - case USER: - return new UserId(uuid); - case DASHBOARD: - return new DashboardId(uuid); - case DEVICE: - return new DeviceId(uuid); - case ASSET: - return new AssetId(uuid); - case ALARM: - return new AlarmId(uuid); - case RULE_CHAIN: - return new RuleChainId(uuid); - case ENTITY_VIEW: - return new EntityViewId(uuid); - case WIDGETS_BUNDLE: - return new WidgetsBundleId(uuid); - case WIDGET_TYPE: - return new WidgetTypeId(uuid); - case DEVICE_PROFILE: - return new DeviceProfileId(uuid); - case ASSET_PROFILE: - return new AssetProfileId(uuid); - case TENANT_PROFILE: - return new TenantProfileId(uuid); - case OTA_PACKAGE: - return new OtaPackageId(uuid); - case EDGE: - return new EdgeId(uuid); - case QUEUE: - return new QueueId(uuid); - case TB_RESOURCE: - return new TbResourceId(uuid); - case NOTIFICATION_RULE: - return new NotificationRuleId(uuid); - case NOTIFICATION_TARGET: - return new NotificationTargetId(uuid); - case NOTIFICATION_TEMPLATE: - return new NotificationTemplateId(uuid); - case OAUTH2_CLIENT: - return new OAuth2ClientId(uuid); - case DOMAIN: - return new DomainId(uuid); - case CALCULATED_FIELD: - return new CalculatedFieldId(uuid); - } - throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); + return switch (edgeEventType) { + case TENANT -> TenantId.fromUUID(uuid); + case CUSTOMER -> new CustomerId(uuid); + case USER -> new UserId(uuid); + case DASHBOARD -> new DashboardId(uuid); + case DEVICE -> new DeviceId(uuid); + case ASSET -> new AssetId(uuid); + case ALARM -> new AlarmId(uuid); + case RULE_CHAIN -> new RuleChainId(uuid); + case ENTITY_VIEW -> new EntityViewId(uuid); + case WIDGETS_BUNDLE -> new WidgetsBundleId(uuid); + case WIDGET_TYPE -> new WidgetTypeId(uuid); + case DEVICE_PROFILE -> new DeviceProfileId(uuid); + case ASSET_PROFILE -> new AssetProfileId(uuid); + case TENANT_PROFILE -> new TenantProfileId(uuid); + case OTA_PACKAGE -> new OtaPackageId(uuid); + case EDGE -> EdgeId.fromUUID(uuid); + case QUEUE -> new QueueId(uuid); + case TB_RESOURCE -> new TbResourceId(uuid); + case NOTIFICATION_RULE -> new NotificationRuleId(uuid); + case NOTIFICATION_TARGET -> new NotificationTargetId(uuid); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateId(uuid); + case OAUTH2_CLIENT -> new OAuth2ClientId(uuid); + case DOMAIN -> new DomainId(uuid); + case CALCULATED_FIELD -> new CalculatedFieldId(uuid); + default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); + }; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java index 76678b8b31..372452825f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java @@ -20,10 +20,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class JobId extends UUIDBased implements EntityId { + @Serial + private static final long serialVersionUID = -2225072123132918395L; + @JsonCreator public JobId(@JsonProperty("id") UUID id) { super(id); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java index 2a8efc5bfb..a246ab5f94 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java @@ -20,10 +20,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public class OtaPackageId extends UUIDBased implements EntityId { + @Serial private static final long serialVersionUID = 1L; @JsonCreator diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java index 57561fb7ee..7cad1698d7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java @@ -23,6 +23,7 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.springframework.util.ConcurrentReferenceHashMap.ReferenceType; import org.thingsboard.server.common.data.EntityType; +import java.io.Serial; import java.util.UUID; public final class TenantId extends UUIDBased implements EntityId { @@ -33,6 +34,7 @@ public final class TenantId extends UUIDBased implements EntityId { @JsonIgnore public static final TenantId SYS_TENANT_ID = TenantId.fromUUID(EntityId.NULL_UUID); + @Serial private static final long serialVersionUID = 1L; @JsonCreator diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java index f2ee164dcd..f6e9085489 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/RateLimitUtil.java @@ -77,16 +77,4 @@ public class RateLimitUtil { return true; } - @Deprecated(forRemoval = true, since = "4.1") - public static String deduplicateByDuration(String configStr) { - if (configStr == null) { - return null; - } - Set distinctDurations = new HashSet<>(); - return parseConfig(configStr).stream() - .filter(entry -> distinctDurations.add(entry.durationSeconds())) - .map(RateLimitEntry::toString) - .collect(Collectors.joining(",")); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java index e5fefd6072..ef1580c0e3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/app/MobileApp.java @@ -43,6 +43,9 @@ public class MobileApp extends BaseData implements HasTenantId, Has @NotBlank @Length(fieldName = "pkgName") private String pkgName; + @Schema(description = "Application title") + @Length(fieldName = "title") + private String title; @Schema(description = "Application secret. The length must be at least 16 characters", requiredMode = Schema.RequiredMode.REQUIRED) @NotEmpty @Length(fieldName = "appSecret", min = 16, max = 2048, message = "must be at least 16 and max 2048 characters") @@ -72,6 +75,7 @@ public class MobileApp extends BaseData implements HasTenantId, Has super(mobile); this.tenantId = mobile.tenantId; this.pkgName = mobile.pkgName; + this.title = mobile.title; this.appSecret = mobile.appSecret; this.platformType = mobile.platformType; this.status = mobile.status; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index 71a8e8ef33..1ab7ef5b34 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -45,4 +45,8 @@ public class AlarmCountQuery extends EntityCountQuery { super(entityFilter); } + public AlarmCountQuery(EntityFilter entityFilter, List keyFilters) { + super(entityFilter, keyFilters); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java index 31547246c6..c63682b899 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmData.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.query; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.EqualsAndHashCode; import lombok.Getter; import org.thingsboard.server.common.data.alarm.Alarm; @@ -34,6 +36,12 @@ public class AlarmData extends AlarmInfo { @Getter private final Map> latest; + @JsonCreator + public AlarmData(@JsonProperty("entityId") EntityId entityId, @JsonProperty("latest") Map> latest) { + this.entityId = entityId; + this.latest = latest; + } + public AlarmData(AlarmInfo main, AlarmData prototype) { super(main); this.entityId = prototype.entityId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityId.java new file mode 100644 index 0000000000..51c3dcdd53 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityId.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.query; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.Schema; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; + +@JsonDeserialize(using = AliasEntityIdDeserializer.class) +@JsonSerialize(using = AliasEntityIdSerializer.class) +@Schema +public interface AliasEntityId extends EntityId { + + AliasEntityType getAliasEntityType(); + + EntityId defaultEntityId(); + + EntityId toEntityId(); + + @JsonIgnore + default boolean isAliasEntityId() { + return getAliasEntityType() != null; + } + + static AliasEntityId fromEntityId(EntityId entityId) { + if (entityId != null) { + return new AliasEntityIdImpl(entityId); + } else { + return null; + } + } + + static AliasEntityId resolveAliasEntityId(AliasEntityId aliasEntityId, TenantId tenantId, UserId userId, EntityId userOwnerId) { + if (aliasEntityId != null) { + if (aliasEntityId.isAliasEntityId()) { + AliasEntityType aliasEntityType = aliasEntityId.getAliasEntityType(); + switch (aliasEntityType) { + case CURRENT_CUSTOMER -> { + if (EntityType.CUSTOMER.equals(userOwnerId.getEntityType())) { + return fromEntityId(userOwnerId); + } else { + return fromEntityId(aliasEntityId.defaultEntityId()); + } + } + case CURRENT_TENANT -> { + return fromEntityId(tenantId); + } + case CURRENT_USER -> { + return fromEntityId(userId); + } + case CURRENT_USER_OWNER -> { + return fromEntityId(userOwnerId); + } + } + } else { + return aliasEntityId; + } + } + return null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdDeserializer.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdDeserializer.java new file mode 100644 index 0000000000..488bd3eb3b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdDeserializer.java @@ -0,0 +1,58 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.query; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.ObjectCodec; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; + +import java.io.IOException; +import java.util.UUID; + +public class AliasEntityIdDeserializer extends JsonDeserializer { + + @Override + public AliasEntityId deserialize(JsonParser jsonParser, DeserializationContext ctx) throws IOException, JacksonException { + ObjectCodec oc = jsonParser.getCodec(); + ObjectNode node = oc.readTree(jsonParser); + if (node.has("entityType")) { + String entityType = node.get("entityType").asText(); + try { + EntityType.valueOf(entityType); + if (!node.has("id")) { + throw new IOException("Missing entityType or id!"); + } + EntityId entityId = EntityIdFactory.getByTypeAndId(node.get("entityType").asText(), node.get("id").asText()); + return new AliasEntityIdImpl(entityId); + } catch (IllegalArgumentException e) { + AliasEntityType aliasEntityType = AliasEntityType.valueOf(entityType); + UUID id = null; + if (node.has("id")) { + id = UUID.fromString(node.get("id").asText()); + } + return new AliasEntityIdImpl(aliasEntityType, id); + } + } else { + throw new IOException("Missing entityType!"); + } + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdImpl.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdImpl.java new file mode 100644 index 0000000000..6ab1e9875d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdImpl.java @@ -0,0 +1,104 @@ +/** + * 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.query; + +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; + +import java.util.UUID; + +class AliasEntityIdImpl implements AliasEntityId { + + private UUID id; + private EntityType entityType; + private AliasEntityType aliasEntityType; + private EntityId defaultEntityId; + + protected AliasEntityIdImpl(EntityId entityId) { + this.id = entityId.getId(); + this.entityType = entityId.getEntityType(); + } + + protected AliasEntityIdImpl(AliasEntityType aliasEntityType, UUID id) { + this.aliasEntityType = aliasEntityType; + if (id != null) { + switch (this.aliasEntityType) { + case CURRENT_CUSTOMER: + this.defaultEntityId = new CustomerId(id); + break; + } + } + } + + @Override + public AliasEntityType getAliasEntityType() { + return aliasEntityType; + } + + @Override + public EntityId defaultEntityId() { + return defaultEntityId; + } + + @Override + public EntityId toEntityId() { + return EntityIdFactory.getByTypeAndUuid(entityType, id); + } + + @Override + public UUID getId() { + return id; + } + + @Override + public EntityType getEntityType() { + return entityType; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (!(obj instanceof EntityId otherEntityId)) + return false; + if (obj instanceof AliasEntityId otherAliasEntityId) { + if (otherAliasEntityId.isAliasEntityId()) { + if (!this.isAliasEntityId()) { + return false; + } + if (this.aliasEntityType != otherAliasEntityId.getAliasEntityType()) { + return false; + } + if (this.defaultEntityId != null && !this.defaultEntityId.equals(otherAliasEntityId.defaultEntityId())) { + return false; + } + if (this.defaultEntityId == null && otherAliasEntityId.defaultEntityId() != null) { + return false; + } + } + } + if (this.isAliasEntityId()) { + return false; + } + if (id == null) { + return otherEntityId.getId() == null; + } else return id.equals(otherEntityId.getId()); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdSerializer.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdSerializer.java new file mode 100644 index 0000000000..cd115f2ca6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityIdSerializer.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.query; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import java.io.IOException; +import java.util.UUID; + +public class AliasEntityIdSerializer extends JsonSerializer { + @Override + public void serialize(AliasEntityId value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + String entityType; + if (value.isAliasEntityId()) { + entityType = value.getAliasEntityType().name(); + } else { + entityType = value.getEntityType().name(); + } + gen.writeStringField("entityType", entityType); + UUID id = null; + if (value.getId() != null) { + id = value.getId(); + } else if (value.defaultEntityId() != null) { + id = value.defaultEntityId().getId(); + } + if (id != null) { + gen.writeStringField("id", id.toString()); + } + gen.writeEndObject(); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityType.java new file mode 100644 index 0000000000..ebcb41a2eb --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AliasEntityType.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.query; + +public enum AliasEntityType { + CURRENT_CUSTOMER, + CURRENT_TENANT, + CURRENT_USER, + CURRENT_USER_OWNER +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java index 5507f53f08..9c0696f97c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java @@ -19,6 +19,11 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; + +import static org.thingsboard.server.common.data.query.AliasEntityId.resolveAliasEntityId; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( @@ -45,4 +50,17 @@ public interface EntityFilter { @JsonIgnore EntityFilterType getType(); + + static void resolveEntityFilter(EntityFilter filter, TenantId tenantId, UserId userId, EntityId userOwnerId) { + if (filter instanceof SingleEntityFilter singleEntityFilter) { + AliasEntityId resolved = resolveAliasEntityId(singleEntityFilter.getSingleEntity(), tenantId, userId, userOwnerId); + singleEntityFilter.setSingleEntity(resolved); + } else if (filter instanceof RelationsQueryFilter queryFilter) { + AliasEntityId resolved = resolveAliasEntityId(queryFilter.getRootEntity(), tenantId, userId, userOwnerId); + queryFilter.setRootEntity(resolved); + } else if (filter instanceof EntitySearchQueryFilter queryFilter) { + AliasEntityId resolved = resolveAliasEntityId(queryFilter.getRootEntity(), tenantId, userId, userOwnerId); + queryFilter.setRootEntity(resolved); + } + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java index babcc466bd..873aadbebc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntitySearchQueryFilter.java @@ -22,10 +22,12 @@ import org.thingsboard.server.common.data.relation.EntitySearchDirection; @Data public abstract class EntitySearchQueryFilter implements EntityFilter { - private EntityId rootEntity; + private AliasEntityId rootEntity; private String relationType; private EntitySearchDirection direction; private int maxLevel; private boolean fetchLastLevelOnly; + private boolean rootStateEntity; + private AliasEntityId defaultStateEntity; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java index 83aad4eaaf..fb19857428 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java @@ -32,7 +32,7 @@ public class RelationsQueryFilter implements EntityFilter { return EntityFilterType.RELATIONS_QUERY; } - private EntityId rootEntity; + private AliasEntityId rootEntity; private boolean isMultiRoot; private EntityType multiRootEntitiesType; private Set multiRootEntityIds; @@ -41,5 +41,7 @@ public class RelationsQueryFilter implements EntityFilter { private int maxLevel; private boolean fetchLastLevelOnly; private boolean negate; + private boolean rootStateEntity; + private AliasEntityId defaultStateEntity; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java index 2e538806b9..8aa7d4e63b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java @@ -25,6 +25,6 @@ public class SingleEntityFilter implements EntityFilter { return EntityFilterType.SINGLE_ENTITY; } - private EntityId singleEntity; + private AliasEntityId singleEntity; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java index c763daeb7f..79dfbe6be2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/JsonTbEntity.java @@ -25,7 +25,9 @@ import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.ai.AiModel; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.notification.rule.NotificationRule; @@ -58,8 +60,9 @@ import java.lang.annotation.Target; @Type(name = "NOTIFICATION_TEMPLATE", value = NotificationTemplate.class), @Type(name = "NOTIFICATION_TARGET", value = NotificationTarget.class), @Type(name = "NOTIFICATION_RULE", value = NotificationRule.class), - @Type(name = "TB_RESOURCE", value = TbResource.class) + @Type(name = "TB_RESOURCE", value = TbResource.class), + @Type(name = "OTA_PACKAGE", value = OtaPackage.class), + @Type(name = "AI_MODEL", value = AiModel.class) }) @JsonIgnoreProperties(value = {"tenantId", "createdTime", "version"}, ignoreUnknown = true) -public @interface JsonTbEntity { -} +public @interface JsonTbEntity {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java index a1692aef04..5e1f98638a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java @@ -41,7 +41,8 @@ import java.util.Map; @Type(name = "DEVICE", value = DeviceExportData.class), @Type(name = "RULE_CHAIN", value = RuleChainExportData.class), @Type(name = "WIDGET_TYPE", value = WidgetTypeExportData.class), - @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundleExportData.class) + @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundleExportData.class), + @Type(name = "OTA_PACKAGE", value = OtaPackageExportData.class) }) @JsonInclude(JsonInclude.Include.NON_NULL) @Data diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java new file mode 100644 index 0000000000..44e2f7857c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.sync.ie; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.OtaPackage; + +@EqualsAndHashCode(callSuper = true) +public class OtaPackageExportData extends EntityExportData { + + /* + * OtaPackage is not a versioned entity; its 'version' field is part of the domain model (not used for optimistic locking) + * We override both methods to ensure 'version' is not ignored during (de)serialization. + */ + @JsonIgnoreProperties(value = {"tenantId", "createdTime"}, ignoreUnknown = true) + @Override + public OtaPackage getEntity() { + return super.getEntity(); + } + + @JsonIgnoreProperties(value = {"tenantId", "createdTime"}, ignoreUnknown = true) + @Override + public void setEntity(OtaPackage entity) { + super.setEntity(entity); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java index 8e61991b38..b5e2813134 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/AutoVersionCreateConfig.java @@ -18,10 +18,13 @@ package org.thingsboard.server.common.data.sync.vc.request.create; import lombok.Data; import lombok.EqualsAndHashCode; +import java.io.Serial; + @EqualsAndHashCode(callSuper = true) @Data public class AutoVersionCreateConfig extends VersionCreateConfig { + @Serial private static final long serialVersionUID = 8245450889383315551L; private String branch; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index a4ff47c340..246fe46791 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -24,7 +24,6 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.TenantProfileType; -import org.thingsboard.server.common.data.limit.RateLimitUtil; import org.thingsboard.server.common.data.validation.RateLimit; import java.io.Serial; @@ -236,43 +235,4 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura return maxRuleNodeExecutionsPerMessage; } - @Deprecated(forRemoval = true, since = "4.1") - public void deduplicateRateLimitsConfigs() { - this.transportTenantMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportTenantMsgRateLimit); - this.transportTenantTelemetryMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportTenantTelemetryMsgRateLimit); - this.transportTenantTelemetryDataPointsRateLimit = RateLimitUtil.deduplicateByDuration(transportTenantTelemetryDataPointsRateLimit); - - this.transportDeviceMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportDeviceMsgRateLimit); - this.transportDeviceTelemetryMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportDeviceTelemetryMsgRateLimit); - this.transportDeviceTelemetryDataPointsRateLimit = RateLimitUtil.deduplicateByDuration(transportDeviceTelemetryDataPointsRateLimit); - - this.transportGatewayMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportGatewayMsgRateLimit); - this.transportGatewayTelemetryMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportGatewayTelemetryMsgRateLimit); - this.transportGatewayTelemetryDataPointsRateLimit = RateLimitUtil.deduplicateByDuration(transportGatewayTelemetryDataPointsRateLimit); - - this.transportGatewayDeviceMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportGatewayDeviceMsgRateLimit); - this.transportGatewayDeviceTelemetryMsgRateLimit = RateLimitUtil.deduplicateByDuration(transportGatewayDeviceTelemetryMsgRateLimit); - this.transportGatewayDeviceTelemetryDataPointsRateLimit = RateLimitUtil.deduplicateByDuration(transportGatewayDeviceTelemetryDataPointsRateLimit); - - this.tenantEntityExportRateLimit = RateLimitUtil.deduplicateByDuration(tenantEntityExportRateLimit); - this.tenantEntityImportRateLimit = RateLimitUtil.deduplicateByDuration(tenantEntityImportRateLimit); - this.tenantNotificationRequestsRateLimit = RateLimitUtil.deduplicateByDuration(tenantNotificationRequestsRateLimit); - this.tenantNotificationRequestsPerRuleRateLimit = RateLimitUtil.deduplicateByDuration(tenantNotificationRequestsPerRuleRateLimit); - - this.cassandraReadQueryTenantCoreRateLimits = RateLimitUtil.deduplicateByDuration(cassandraReadQueryTenantCoreRateLimits); - this.cassandraWriteQueryTenantCoreRateLimits = RateLimitUtil.deduplicateByDuration(cassandraWriteQueryTenantCoreRateLimits); - this.cassandraReadQueryTenantRuleEngineRateLimits = RateLimitUtil.deduplicateByDuration(cassandraReadQueryTenantRuleEngineRateLimits); - this.cassandraWriteQueryTenantRuleEngineRateLimits = RateLimitUtil.deduplicateByDuration(cassandraWriteQueryTenantRuleEngineRateLimits); - - this.edgeEventRateLimits = RateLimitUtil.deduplicateByDuration(edgeEventRateLimits); - this.edgeEventRateLimitsPerEdge = RateLimitUtil.deduplicateByDuration(edgeEventRateLimitsPerEdge); - this.edgeUplinkMessagesRateLimits = RateLimitUtil.deduplicateByDuration(edgeUplinkMessagesRateLimits); - this.edgeUplinkMessagesRateLimitsPerEdge = RateLimitUtil.deduplicateByDuration(edgeUplinkMessagesRateLimitsPerEdge); - - this.wsUpdatesPerSessionRateLimit = RateLimitUtil.deduplicateByDuration(wsUpdatesPerSessionRateLimit); - - this.tenantServerRestLimitsConfiguration = RateLimitUtil.deduplicateByDuration(tenantServerRestLimitsConfiguration); - this.customerServerRestLimitsConfiguration = RateLimitUtil.deduplicateByDuration(customerServerRestLimitsConfiguration); - } - } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java new file mode 100644 index 0000000000..51399a9634 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/NoNullChar.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = {}) +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface NoNullChar { + + String message() default "should not contain 0x00 symbol"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java b/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java new file mode 100644 index 0000000000..d37d7eb9e7 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/validation/ValidJsonSchema.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = {}) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidJsonSchema { + + String message() default "must conform to the Draft 2020-12 meta-schema"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 8d03a58bdf..4f9ef70c5b 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 9185c7dc7b..69a01be812 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index e1ff386a32..0eff7ad053 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/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_1_0) + .setEdgeVersion(EdgeVersion.V_4_2_0) .setMaxInboundMessageSize(maxInboundMessageSize) .build()) .build()); diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index c805f42e8c..dbda462a99 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -43,6 +43,7 @@ enum EdgeVersion { V_3_9_0 = 9; V_4_0_0 = 10; V_4_1_0 = 11; + V_4_2_0 = 12; V_LATEST = 999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 4cb38ab7fe..140eabd270 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java index 52205e2f72..6c67b467eb 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java @@ -33,6 +33,16 @@ public class StringDataPoint extends AbstractDataPoint { this.value = deduplicate ? TbStringPool.intern(value) : value; } + @Override + public double getDouble() { + return Double.parseDouble(value); + } + + @Override + public long getLong() { + return Long.parseLong(value); + } + @Override public DataType getType() { return DataType.STRING; diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java index 0e74cb98fa..13a54973e6 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/processor/EdqsProcessor.java @@ -203,6 +203,25 @@ public class EdqsProcessor implements TbQueueHandler, }); } + @Override + public TbProtoQueueMsg constructErrorResponseMsg(TbProtoQueueMsg request, Throwable e) { + EdqsResponse response = new EdqsResponse(); + String errorMessage; + if (e instanceof org.apache.kafka.common.errors.RecordTooLargeException) { + errorMessage = "Result set is too large"; + } else if (e instanceof IllegalArgumentException || e instanceof NullPointerException) { + errorMessage = "Invalid request format or missing data: " + ExceptionUtil.getMessage(e); + } else { + errorMessage = ExceptionUtil.getMessage(e); + } + response.setError(errorMessage); + return new TbProtoQueueMsg<>(request.getKey(), FromEdqsMsg.newBuilder() + .setResponseMsg(TransportProtos.EdqsResponseMsg.newBuilder() + .setValue(JacksonUtil.toString(response)) + .build()) + .build(), request.getHeaders()); + } + private EdqsResponse processRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { EdqsResponse response = new EdqsResponse(); try { diff --git a/common/message/pom.xml b/common/message/pom.xml index 8e6a247ae9..834a041127 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java index e703183883..60fb1df6aa 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.msg; +import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import java.io.Serializable; @@ -23,9 +24,6 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -/** - * Created by ashvayka on 13.01.18. - */ @Data public final class TbMsgMetaData implements Serializable { @@ -34,7 +32,7 @@ public final class TbMsgMetaData implements Serializable { private final Map data; public TbMsgMetaData() { - this.data = new ConcurrentHashMap<>(); + data = new ConcurrentHashMap<>(); } public TbMsgMetaData(Map data) { @@ -46,24 +44,30 @@ public final class TbMsgMetaData implements Serializable { * Internal constructor to create immutable TbMsgMetaData.EMPTY * */ private TbMsgMetaData(int ignored) { - this.data = Collections.emptyMap(); + data = Collections.emptyMap(); } public String getValue(String key) { - return this.data.get(key); + return data.get(key); } public void putValue(String key, String value) { if (key != null && value != null) { - this.data.put(key, value); + data.put(key, value); } } public Map values() { - return new HashMap<>(this.data); + return new HashMap<>(data); } public TbMsgMetaData copy() { - return new TbMsgMetaData(this.data); + return new TbMsgMetaData(data); } + + @JsonIgnore + public boolean isEmpty() { + return data == null || data.isEmpty(); + } + } diff --git a/common/pom.xml b/common/pom.xml index 2e277f095f..563f1ec3ab 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 75a71c434d..6357cae711 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java index 2a208923d9..6518cfc3fe 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/JsonConverter.java @@ -56,11 +56,9 @@ import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509Ce import java.math.BigDecimal; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.Set; import java.util.TreeMap; import java.util.function.Consumer; @@ -538,13 +536,13 @@ public class JsonConverter { return result; } - public static Set convertToAttributes(JsonElement element) { + public static List convertToAttributes(JsonElement element) { long ts = System.currentTimeMillis(); return convertToAttributes(element, ts); } - public static Set convertToAttributes(JsonElement element, long ts) { - return new HashSet<>(parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).toList()); + public static List convertToAttributes(JsonElement element, long ts) { + return parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).toList(); } private static List parseValues(JsonObject valuesObject) { diff --git a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java index 3cded5a491..6d010a7a0d 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/util/ProtoUtils.java @@ -252,7 +252,7 @@ public class ProtoUtils { public static EdgeEvent fromProto(TransportProtos.EdgeEventMsgProto proto) { EdgeEvent edgeEvent = new EdgeEvent(); - TenantId tenantId = new TenantId(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); + TenantId tenantId = TenantId.fromUUID(new UUID(proto.getTenantIdMSB(), proto.getTenantIdLSB())); edgeEvent.setTenantId(tenantId); edgeEvent.setType(EdgeEventType.valueOf(proto.getEntityType())); edgeEvent.setAction(EdgeEventActionType.valueOf(proto.getAction())); @@ -845,7 +845,7 @@ public class ProtoUtils { public static Device fromProto(TransportProtos.DeviceProto proto) { Device device = new Device(getEntityId(proto.getDeviceIdMSB(), proto.getDeviceIdLSB(), DeviceId::new)); device.setCreatedTime(proto.getCreatedTime()); - device.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + device.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); device.setName(proto.getDeviceName()); device.setType(proto.getDeviceType()); device.setDeviceProfileId(getEntityId(proto.getDeviceProfileIdMSB(), proto.getDeviceProfileIdLSB(), DeviceProfileId::new)); @@ -937,7 +937,7 @@ public class ProtoUtils { public static DeviceProfile fromProto(TransportProtos.DeviceProfileProto proto) { DeviceProfile deviceProfile = new DeviceProfile(getEntityId(proto.getDeviceProfileIdMSB(), proto.getDeviceProfileIdLSB(), DeviceProfileId::new)); deviceProfile.setCreatedTime(proto.getCreatedTime()); - deviceProfile.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + deviceProfile.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); deviceProfile.setName(proto.getName()); deviceProfile.setDefault(proto.getIsDefault()); deviceProfile.setType(DeviceProfileType.valueOf(proto.getType())); @@ -1028,7 +1028,7 @@ public class ProtoUtils { } public static Tenant fromProto(TransportProtos.TenantProto proto) { - Tenant tenant = new Tenant(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + Tenant tenant = new Tenant(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); tenant.setCreatedTime(proto.getCreatedTime()); tenant.setTenantProfileId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantProfileId::new)); tenant.setTitle(proto.getTitle()); @@ -1142,7 +1142,7 @@ public class ProtoUtils { public static TbResource fromProto(TransportProtos.TbResourceProto proto) { TbResource resource = new TbResource(getEntityId(proto.getResourceIdMSB(), proto.getResourceIdLSB(), TbResourceId::new)); - resource.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::new)); + resource.setTenantId(getEntityId(proto.getTenantIdMSB(), proto.getTenantIdLSB(), TenantId::fromUUID)); resource.setCreatedTime(proto.getCreatedTime()); resource.setTitle(proto.getTitle()); resource.setResourceType(ResourceType.valueOf(proto.getResourceType())); @@ -1198,7 +1198,7 @@ public class ProtoUtils { public static ApiUsageState fromProto(TransportProtos.ApiUsageStateProto proto) { ApiUsageState apiUsageState = new ApiUsageState(getEntityId(proto.getApiUsageStateIdMSB(), proto.getApiUsageStateIdLSB(), ApiUsageStateId::new)); - apiUsageState.setTenantId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantId::new)); + apiUsageState.setTenantId(getEntityId(proto.getTenantProfileIdMSB(), proto.getTenantProfileIdLSB(), TenantId::fromUUID)); apiUsageState.setCreatedTime(proto.getCreatedTime()); apiUsageState.setEntityId(EntityIdFactory.getByTypeAndUuid(fromProto(proto.getEntityType()), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB()))); apiUsageState.setTransportState(ApiUsageStateValue.valueOf(proto.getTransportState())); diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 2667838b60..3870f17c5b 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -64,6 +64,8 @@ enum EntityTypeProto { CALCULATED_FIELD = 39; CALCULATED_FIELD_LINK = 40; JOB = 41; + ADMIN_SETTINGS = 42; + AI_MODEL = 43; } enum ApiUsageRecordKeyProto { diff --git a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java index 28b65a7be8..38ef604ab0 100644 --- a/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java +++ b/common/proto/src/test/java/org/thingsboard/server/common/adaptor/JsonConverterTest.java @@ -23,8 +23,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Isolated; -import java.util.ArrayList; - @Isolated("JsonConverter static settings being modified") public class JsonConverterTest { @@ -53,7 +51,7 @@ public class JsonConverterTest { @Test public void testParseAttributesBigDecimalAsLong() { - var result = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString("{\"meterReadingDelta\": 1E1}"))); + var result = JsonConverter.convertToAttributes(JsonParser.parseString("{\"meterReadingDelta\": 1E1}")); Assertions.assertEquals(10L, result.get(0).getLongValue().get().longValue()); } @@ -108,4 +106,5 @@ public class JsonConverterTest { JsonConverter.convertToTelemetry(JsonParser.parseString("{\"meterReadingDelta\": 9.9701010061400066E19}"), 0L); }); } + } diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 0bed40051b..ccc0eadf1a 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java index 7e913009f0..ef1682b97f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/PartitionedQueueResponseTemplate.java @@ -21,9 +21,11 @@ import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.stats.MessagesStats; +import org.thingsboard.server.queue.TbQueueCallback; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueHandler; import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.TbQueueMsgMetadata; import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.common.consumer.PartitionedQueueConsumerManager; @@ -119,8 +121,20 @@ public class PartitionedQueueResponseTemplate { pendingRequestCount.decrementAndGet(); response.getHeaders().put(REQUEST_ID_HEADER, uuidToBytes(requestId)); - responseProducer.send(TopicPartitionInfo.builder().topic(responseTopic).build(), response, null); - stats.incrementSuccessful(); + TopicPartitionInfo tpi = TopicPartitionInfo.builder().topic(responseTopic).build(); + responseProducer.send(tpi, response, new TbQueueCallback() { + @Override + public void onSuccess(TbQueueMsgMetadata metadata) { + stats.incrementSuccessful(); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to send response {}", requestId, response, t); + sendErrorResponse(requestId, tpi, request, t); + stats.incrementFailed(); + } + }); }, e -> { pendingRequestCount.decrementAndGet(); @@ -144,6 +158,15 @@ public class PartitionedQueueResponseTemplate partitions) { requestConsumer.update(partitions); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java index c3658d098a..dd8afd735e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/EdqsComponent.java @@ -17,12 +17,13 @@ package org.thingsboard.server.queue.edqs; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && ('${service.type:null}'=='edqs' || " + "(('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && " + "'${queue.edqs.mode:null}'=='local'))") -public @interface EdqsComponent { -} +public @interface EdqsComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java index e414d24fd9..3e1d1411aa 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/InMemoryEdqsComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.edqs; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && '${service.type:null}'=='monolith' && '${queue.edqs.mode:null}'=='local' && '${queue.type:null}'=='in-memory'") -public @interface InMemoryEdqsComponent { -} +public @interface InMemoryEdqsComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java index 3a2b282724..36593bfa1c 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/edqs/KafkaEdqsComponent.java @@ -17,12 +17,13 @@ package org.thingsboard.server.queue.edqs; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${queue.edqs.sync.enabled:true}'=='true' && ('${service.type:null}'=='edqs' || " + "(('${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core') && " + "'${queue.edqs.mode:null}'=='local' && '${queue.type:null}'=='kafka'))") -public @interface KafkaEdqsComponent { -} +public @interface KafkaEdqsComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java index bf6bfc7a2d..fb6ce213c2 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbCoreComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") -public @interface TbCoreComponent { -} +public @interface TbCoreComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java index 9216b40c7a..735df499fe 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mBootstrapTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("('${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.lwm2m.enabled}'=='true')) && '${transport.lwm2m.bootstrap.enabled:false}'=='true'") -public @interface TbLwM2mBootstrapTransportComponent { -} +public @interface TbLwM2mBootstrapTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java index ddc3093349..8055870872 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbLwM2mTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.lwm2m.enabled}'=='true')") -public @interface TbLwM2mTransportComponent { -} +public @interface TbLwM2mTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java index ef77763a07..0b03de3570 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbRuleEngineComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-rule-engine'") -public @interface TbRuleEngineComponent { -} +public @interface TbRuleEngineComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java index ca84ea7305..914b5456ff 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbSnmpTransportComponent.java @@ -18,12 +18,13 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; +@Inherited @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.snmp.enabled}'=='true')") @Retention(RetentionPolicy.RUNTIME) @Target({ElementType.TYPE, ElementType.METHOD}) -public @interface TbSnmpTransportComponent { -} +public @interface TbSnmpTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java index c879deadf3..3d6a0416e8 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true') || '${service.type:null}'=='tb-transport'") -public @interface TbTransportComponent { -} +public @interface TbTransportComponent {} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java index 99132f1877..1c1fdd041d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/util/TbVersionControlComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.queue.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='monolith' || '${service.type:null}'=='tb-vc-executor'") -public @interface TbVersionControlComponent { -} +public @interface TbVersionControlComponent {} diff --git a/common/script/pom.xml b/common/script/pom.xml index 0a7448d837..c203bfdbf2 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index b14cd0ef45..c2f5ee2da2 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index d64fd6de4c..e35dec1b1b 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC script org.thingsboard.common.script diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index b782040a99..072a17835d 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -23,6 +23,7 @@ import org.mvel2.ExecutionContext; import org.mvel2.ParserConfiguration; import org.mvel2.execution.ExecutionArrayList; import org.mvel2.execution.ExecutionHashMap; +import org.mvel2.execution.ExecutionLinkedHashSet; import org.mvel2.util.MethodStub; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.geo.Coordinates; @@ -46,6 +47,7 @@ import java.util.Base64; import java.util.Collection; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -386,6 +388,12 @@ public class TbUtils { Object.class))); parserConfig.addImport("isArray", new MethodStub(TbUtils.class.getMethod("isArray", Object.class))); + parserConfig.addImport("newSet", new MethodStub(TbUtils.class.getMethod("newSet", + ExecutionContext.class))); + parserConfig.addImport("toSet", new MethodStub(TbUtils.class.getMethod("toSet", + ExecutionContext.class, List.class))); + parserConfig.addImport("isSet", new MethodStub(TbUtils.class.getMethod("isSet", + Object.class))); } public static String btoa(String input) { @@ -1481,6 +1489,19 @@ public class TbUtils { return obj != null && obj.getClass().isArray(); } + public static Set newSet(ExecutionContext ctx) { + return new ExecutionLinkedHashSet<>(ctx); + } + + public static Set toSet(ExecutionContext ctx, List list) { + Set newSet = new LinkedHashSet<>(list); + return new ExecutionLinkedHashSet<>(newSet, ctx); + } + + public static boolean isSet(Object obj) { + return obj instanceof Set; + } + private static byte isValidIntegerToByte(Integer val) { if (val > 255 || val < -128) { throw new NumberFormatException("The value '" + val + "' could not be correctly converted to a byte. " + diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index 6d793f2c8d..4dcbd2d69c 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -28,6 +28,7 @@ import org.mvel2.ParserContext; import org.mvel2.SandboxedParserConfiguration; import org.mvel2.execution.ExecutionArrayList; import org.mvel2.execution.ExecutionHashMap; +import org.mvel2.execution.ExecutionLinkedHashSet; import java.io.IOException; import java.math.BigDecimal; @@ -39,14 +40,18 @@ import java.util.Base64; import java.util.Calendar; import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.Set; import java.util.concurrent.ExecutionException; import static java.lang.Character.MAX_RADIX; import static java.lang.Character.MIN_RADIX; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @Slf4j @@ -1184,10 +1189,11 @@ public class TbUtilsTest { @Test public void isList() throws ExecutionException, InterruptedException { - List liat = List.of(0x35); - assertTrue(TbUtils.isList(liat)); - assertFalse(TbUtils.isMap(liat)); - assertFalse(TbUtils.isArray(liat)); + List list = List.of(0x35); + assertTrue(TbUtils.isList(list)); + assertFalse(TbUtils.isMap(list)); + assertFalse(TbUtils.isArray(list)); + assertFalse(TbUtils.isSet(list)); } @Test @@ -1195,6 +1201,52 @@ public class TbUtilsTest { byte [] array = new byte[]{1, 2, 3}; assertTrue(TbUtils.isArray(array)); assertFalse(TbUtils.isList(array)); + assertFalse(TbUtils.isSet(array)); + } + + @Test + public void isSet() throws ExecutionException, InterruptedException { + Set set = toSet(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xBB, (byte) 0xAA}); + assertTrue(TbUtils.isSet(set)); + assertFalse(TbUtils.isList(set)); + assertFalse(TbUtils.isArray(set)); + } + @Test + public void setTest() throws ExecutionException, InterruptedException { + Set actual = TbUtils.newSet(ctx); + Set expected = toSet(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xCC}); + actual.add((byte) 0xDD); + actual.add((byte) 0xCC); + actual.add((byte) 0xCC); + assertTrue(expected.containsAll(actual)); + List list = toList(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xBB, (byte) 0xAA}); + actual.addAll(list); + assertEquals(4, actual.size()); + assertTrue(actual.containsAll(expected)); + actual = TbUtils.toSet(ctx, list); + expected = toSet(new byte[]{(byte) 0xDD, (byte) 0xCC, (byte) 0xDA}); + actual.add((byte) 0xDA); + actual.remove((byte) 0xBB); + actual.remove((byte) 0xAA); + assertTrue(expected.containsAll(actual)); + assertEquals(actual.size(), 3); + actual.clear(); + assertTrue(actual.isEmpty()); + actual = TbUtils.toSet(ctx, list); + Set actualClone = TbUtils.toSet(ctx, list); + Set actualClone_asc = TbUtils.toSet(ctx, list); + Set actualClone_desc = TbUtils.toSet(ctx, list); + ((ExecutionLinkedHashSet)actualClone).sort(); + ((ExecutionLinkedHashSet)actualClone_asc).sort(true); + ((ExecutionLinkedHashSet)actualClone_desc).sort(false); + assertEquals(list.toString(), actual.toString()); + assertNotEquals(list.toString(), actualClone.toString()); + Collections.sort(list); + assertEquals(list.toString(), actualClone.toString()); + assertEquals(list.toString(), actualClone_asc.toString()); + Collections.sort(list, Collections.reverseOrder()); + assertNotEquals(list.toString(), actualClone_asc.toString()); + assertEquals(list.toString(), actualClone_desc.toString()); } private static List toList(byte[] data) { @@ -1204,5 +1256,13 @@ public class TbUtilsTest { } return result; } + + private static Set toSet(byte[] data) { + Set result = new LinkedHashSet<>(); + for (Byte b : data) { + result.add(b); + } + return result; + } } diff --git a/common/stats/pom.xml b/common/stats/pom.xml index 0c425d5e2b..090732d175 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index ac0a036f1f..aaa9713e02 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index a2cdbfc0dc..29d633d83e 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index 74955a8586..4d7013a2bc 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java index 37f7829c12..431b623da9 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java @@ -383,7 +383,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl LwM2mPath path = instant.getKey(); LwM2mNode node = instant.getValue(); LwM2mClient lwM2MClient = clientContext.getClientByEndpoint(registration.getEndpoint()); - ObjectModel objectModelVersion = lwM2MClient.getObjectModel(path.toString(), modelProvider); + ObjectModel objectModelVersion = lwM2MClient.getObjectModel(convertObjectIdToVersionedId(path.toString(), lwM2MClient), modelProvider); if (objectModelVersion != null) { ResourceUpdateResult updateResource = new ResourceUpdateResult(lwM2MClient); if (node instanceof LwM2mObject) { diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 0f19e9dc29..2baee9bda2 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index 5aea93528a..7f9da9e0b9 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -28,6 +28,8 @@ import io.netty.handler.codec.mqtt.MqttFixedHeader; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttMessageBuilders; import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; +import io.netty.handler.codec.mqtt.MqttProperties.IntegerProperty; +import io.netty.handler.codec.mqtt.MqttProperties.MqttPropertyType; import io.netty.handler.codec.mqtt.MqttPubAckMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; import io.netty.handler.codec.mqtt.MqttQoS; @@ -84,6 +86,7 @@ import org.thingsboard.server.transport.mqtt.limits.SessionLimits; import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; import org.thingsboard.server.transport.mqtt.session.GatewaySessionHandler; import org.thingsboard.server.transport.mqtt.session.MqttTopicMatcher; +import org.thingsboard.server.transport.mqtt.session.SparkplugDeviceSessionContext; import org.thingsboard.server.transport.mqtt.session.SparkplugNodeSessionHandler; import org.thingsboard.server.transport.mqtt.util.ReturnCodeResolver; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType; @@ -101,6 +104,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Map.Entry; import java.util.Optional; import java.util.UUID; import java.util.concurrent.Callable; @@ -120,9 +124,14 @@ import static org.thingsboard.server.common.transport.service.DefaultTransportSe import static org.thingsboard.server.common.transport.service.DefaultTransportService.SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG; import static org.thingsboard.server.common.transport.service.DefaultTransportService.SUBSCRIBE_TO_RPC_ASYNC_MSG; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.OFFLINE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NBIRTH; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NCMD; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NDEATH; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.SPARKPLUG_BD_SEQUENCE_NUMBER_KEY; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProto; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.parseTopicPublish; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProtoFromJsonNode; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic.parseTopic; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.parseTopicPublish; /** * @author Andrew Shvayka @@ -444,11 +453,23 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } + /** + * It may be the case that an Edge Node has many dynamic associated devices. + * Publish: spBv1.0/G1/DBIRTH/E1/+ + * Publish: spBv1.0/G1/DDATA/E1/+ + * Publish: spBv1.0/G1/DCMD/E1/+ + * Publish: spBv1.0/G1/DDEATH/E1/+ + * @param ctx + * @param topicName + * @param mqttMsg + */ + private void handleSparkplugPublishMsg(ChannelHandlerContext ctx, String topicName, MqttPublishMessage mqttMsg) { int msgId = mqttMsg.variableHeader().packetId(); try { SparkplugTopic sparkplugTopic = parseTopicPublish(topicName); - if (sparkplugTopic.isNode()) { + boolean isWildcardInPublish = topicName.contains("+"); + if (!isWildcardInPublish && sparkplugTopic.isNode()) { // A node topic SparkplugBProto.Payload sparkplugBProtoNode = SparkplugBProto.Payload.parseFrom(ProtoMqttAdaptor.toBytes(mqttMsg.payload())); switch (sparkplugTopic.getType()) { @@ -457,21 +478,28 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement case NDATA: sparkplugSessionHandler.onAttributesTelemetryProto(msgId, sparkplugBProtoNode, sparkplugTopic); break; + case NDEATH: + if (sparkplugSessionHandler.onValidateNDEATH(sparkplugBProtoNode)) { + doDisconnect(); + break; + } else { + throw new ThingsboardException(SPARKPLUG_BD_SEQUENCE_NUMBER_KEY + " of " + NDEATH.name() + " is not equals " + + SPARKPLUG_BD_SEQUENCE_NUMBER_KEY + " of " + NBIRTH.name(), ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } default: } } else { // A device topic SparkplugBProto.Payload sparkplugBProtoDevice = SparkplugBProto.Payload.parseFrom(ProtoMqttAdaptor.toBytes(mqttMsg.payload())); - switch (sparkplugTopic.getType()) { - case DBIRTH: - case DCMD: - case DDATA: - sparkplugSessionHandler.onAttributesTelemetryProto(msgId, sparkplugBProtoDevice, sparkplugTopic); - break; - case DDEATH: - sparkplugSessionHandler.onDeviceDisconnect(mqttMsg, sparkplugTopic.getDeviceId()); - break; - default: + if (isWildcardInPublish) { + for (Entry entry : sparkplugSessionHandler.getDevices().entrySet()) { + String deviceName = entry.getKey(); + SparkplugTopic sparkplugTopicDevice = sparkplugTopic; + sparkplugTopicDevice.updateDeviceIdPlus(deviceName); + handleSparkplugPublishDeviceMsg(sparkplugTopicDevice, msgId, mqttMsg, sparkplugBProtoDevice); + } + } else { + handleSparkplugPublishDeviceMsg(sparkplugTopic, msgId, mqttMsg, sparkplugBProtoDevice); } } } catch (RuntimeException e) { @@ -484,6 +512,36 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } + /** + * It may be the case that an Edge Node has many dynamic associated devices. + * Publish: spBv1.0/G1/DBIRTH/E1/+ + * Publish: spBv1.0/G1/DDATA/E1/+ + * Publish: spBv1.0/G1/DCMD/E1/+ + * Publish: spBv1.0/G1/DDEATH/E1/+ + * @param sparkplugTopic + * @param msgId + * @param mqttMsg + * @throws AdaptorException + * @throws ThingsboardException + * @throws InvalidProtocolBufferException + */ + private void handleSparkplugPublishDeviceMsg(SparkplugTopic sparkplugTopic, int msgId, + MqttPublishMessage mqttMsg, SparkplugBProto.Payload sparkplugBProtoDevice) + throws AdaptorException, ThingsboardException, InvalidProtocolBufferException { + // A device topic + switch (sparkplugTopic.getType()) { + case DBIRTH: + case DCMD: + case DDATA: + sparkplugSessionHandler.onAttributesTelemetryProto(msgId, sparkplugBProtoDevice, sparkplugTopic); + break; + case DDEATH: + sparkplugSessionHandler.onDeviceDisconnect(mqttMsg, sparkplugTopic.getDeviceId()); + break; + default: + } + } + private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { try { Matcher fwMatcher; @@ -786,7 +844,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } try { if (sparkplugSessionHandler != null) { - sparkplugSessionHandler.handleSparkplugSubscribeMsg(grantedQoSList, subscription, reqQoS); + sparkplugSessionHandler.handleSparkplugSubscribeMsg(subscription); activityReported = true; } else { switch (topic) { @@ -877,13 +935,22 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement registerSubQoS(topic, grantedQoSList, reqQoS); } - public void processAttributesRpcSubscribeSparkplugNode(List grantedQoSList, MqttQoS reqQoS) { + /** + * 3.0.0 Edge Node Session Establishment: + * ncmd-subscribe + * [tck-id-message-flow-edge-node-ncmd-subscribe] The MQTT client associated with the Edge + * Node MUST subscribe to a topic of the form spBv1.0/group_id/NCMD/edge_node_id where + * group_id is the Sparkplug Group ID and the edge_node_id is the Sparkplug Edge Node ID for + * this Edge Node. It MUST subscribe on this topic with a QoS of 1. + */ + public void processAttributesRpcSubscribeSparkplugNode() { + List grantedQoSList = new ArrayList<>(); transportService.process(TransportProtos.TransportToDeviceActorMsg.newBuilder() .setSessionInfo(deviceSessionCtx.getSessionInfo()) .setSubscribeToAttributes(SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG) .setSubscribeToRPC(SUBSCRIBE_TO_RPC_ASYNC_MSG) .build(), null); - registerSubQoS(MqttTopics.DEVICE_ATTRIBUTES_TOPIC, grantedQoSList, reqQoS); + registerSubQoS(MqttTopics.DEVICE_ATTRIBUTES_TOPIC, grantedQoSList, AT_LEAST_ONCE); } public void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { @@ -1180,12 +1247,14 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement private void checkSparkplugNodeSession(MqttConnectMessage connectMessage, ChannelHandlerContext ctx, SessionMetaData sessionMetaData) { try { if (sparkplugSessionHandler == null) { - SparkplugTopic sparkplugTopicNode = validatedSparkplugTopicConnectedNode(connectMessage); - if (sparkplugTopicNode != null) { + SparkplugTopic sparkplugTopic = validatedSparkplugConnectedWillTopic(connectMessage); + if (sparkplugTopic != null) { SparkplugBProto.Payload sparkplugBProtoNode = SparkplugBProto.Payload.parseFrom(connectMessage.payload().willMessageInBytes()); - sparkplugSessionHandler = new SparkplugNodeSessionHandler(this, deviceSessionCtx, sessionId, true, sparkplugTopicNode); - sparkplugSessionHandler.onAttributesTelemetryProto(0, sparkplugBProtoNode, sparkplugTopicNode); + sparkplugSessionHandler = new SparkplugNodeSessionHandler(this, deviceSessionCtx, sessionId, true, sparkplugTopic); + sparkplugSessionHandler.onAttributesTelemetryProto(0, sparkplugBProtoNode, sparkplugTopic); sessionMetaData.setOverwriteActivityTime(true); + // ncmd-subscribe + processAttributesRpcSubscribeSparkplugNode(); } else { log.trace("[{}][{}] Failed to fetch sparkplugDevice connect: sparkplugTopicName without SparkplugMessageType.NDEATH.", sessionId, deviceSessionCtx.getDeviceInfo().getDeviceName()); throw new ThingsboardException("Invalid request body", ThingsboardErrorCode.BAD_REQUEST_PARAMS); @@ -1198,12 +1267,33 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private SparkplugTopic validatedSparkplugTopicConnectedNode(MqttConnectMessage connectMessage) throws ThingsboardException { + /** + * The Death Certificate topic and payload described here are not “published” as an MQTT message by a client, + * but provided as parameters within the MQTT CONNECT control packet when this Sparkplug Edge Node first establishes the MQTT Client session. + * - NDEATH message MUST be registered as a Will Message in the MQTT CONNECT packet. + * -- in the MQTT CONNECT packet The NDEATH message MUST set the MQTT Will QoS to 1. + * -- in the MQTT CONNECT packet The NDEATH message MUST set the MQTT Will Retained flag to false. + * -- If the MQTT client is using MQTT v3.1.1, the Edge Node’s MQTT CONNECT packet MUST set the Clean Session flag to true. + * -- If the MQTT client is using MQTT v5.0, the Edge Node’s MQTT CONNECT packet MUST set the Clean Start flag to true and the Session Expiry Interval to 0 + * @param connectMessage + * @return + * @throws ThingsboardException + */ + private SparkplugTopic validatedSparkplugConnectedWillTopic(MqttConnectMessage connectMessage) throws ThingsboardException { if (StringUtils.isNotBlank(connectMessage.payload().willTopic()) && connectMessage.payload().willMessageInBytes() != null && connectMessage.payload().willMessageInBytes().length > 0) { - SparkplugTopic sparkplugTopicNode = parseTopicPublish(connectMessage.payload().willTopic()); + SparkplugTopic sparkplugTopicNode = parseTopic(connectMessage.payload().willTopic()); if (NDEATH.equals(sparkplugTopicNode.getType())) { + if (connectMessage.variableHeader().willQos() != 1 || connectMessage.variableHeader().isWillRetain()) + return null; + if (!connectMessage.variableHeader().isCleanSession()) return null; + int mqttVer = connectMessage.variableHeader().version(); + if (mqttVer == 5) { + Object sessionExpiryIntervalObj = connectMessage.variableHeader().properties().isEmpty() ? null : connectMessage.variableHeader().properties().getProperty(MqttPropertyType.SESSION_EXPIRY_INTERVAL.value()); + Integer sessionExpiryInterval = sessionExpiryIntervalObj == null ? null : ((IntegerProperty) sessionExpiryIntervalObj).value(); + if (sessionExpiryInterval == null || sessionExpiryInterval != 0) return null; + } return sparkplugTopicNode; } } @@ -1302,18 +1392,30 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement @Override public void onAttributeUpdate(UUID sessionId, TransportProtos.AttributeUpdateNotificationMsg notification) { - log.trace("[{}] Received attributes update notification to device", sessionId); try { if (sparkplugSessionHandler != null) { - log.trace("[{}] Received attributes update notification to sparkplug device", sessionId); - notification.getSharedUpdatedList().forEach(tsKvProto -> { - if (sparkplugSessionHandler.getNodeBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { - SparkplugTopic sparkplugTopic = new SparkplugTopic(sparkplugSessionHandler.getSparkplugTopicNode(), - SparkplugMessageType.NCMD); + log.trace("[{}] Received attributes update notification to sparkplug Edge Node", sessionId); + notification.getSharedUpdatedList().forEach(tsKvProtoShared -> { + SparkplugMessageType messageType = NCMD; + TransportProtos.TsKvProto tsKvProto = tsKvProtoShared; + if ("JSON_V".equals(tsKvProtoShared.getKv().getType().name())) { + try { + messageType = SparkplugMessageType.parseMessageType(tsKvProtoShared.getKv().getKey()); + tsKvProto = getTsKvProtoFromJsonNode(JacksonUtil.toJsonNode(tsKvProtoShared.getKv().getJsonV()), tsKvProtoShared.getTs()); + } catch (ThingsboardException e) { + messageType = null; + log.error("Failed attributes update notification to sparkplug Edge Node [{}]. ", sparkplugSessionHandler.getSparkplugTopicNode().getEdgeNodeId(), e); + } + } + if (messageType != null && messageType.isSubscribe() && messageType.isNode() + && sparkplugSessionHandler.getNodeBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { + SparkplugTopic sparkplugTopic = new SparkplugTopic(sparkplugSessionHandler.getSparkplugTopicNode(), messageType); sparkplugSessionHandler.createSparkplugMqttPublishMsg(tsKvProto, sparkplugTopic.toString(), sparkplugSessionHandler.getNodeBirthMetrics().get(tsKvProto.getKv().getKey())) .ifPresent(sparkplugSessionHandler::writeAndFlush); + } else { + log.trace("Failed attributes update notification to sparkplug Edge Node [{}]. ", sparkplugSessionHandler.getSparkplugTopicNode().getEdgeNodeId()); } }); } else { @@ -1322,7 +1424,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement adaptor.convertToPublish(deviceSessionCtx, notification, topic).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); } } catch (Exception e) { - log.trace("[{}] Failed to convert device attributes update to MQTT msg", sessionId, e); + log.trace("[{}] Failed to convert device/Edge Node attributes update to MQTT msg", sessionId, e); } } @@ -1359,7 +1461,8 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } - private void onGetSessionLimitsRpc(TransportProtos.SessionInfoProto sessionInfo, ChannelHandlerContext ctx, int msgId, TransportProtos.ToServerRpcRequestMsg rpcRequestMsg) { + private void onGetSessionLimitsRpc(TransportProtos.SessionInfoProto sessionInfo, ChannelHandlerContext ctx, int msgId, TransportProtos. + ToServerRpcRequestMsg rpcRequestMsg) { var tenantProfile = context.getTenantProfileCache().get(deviceSessionCtx.getTenantId()); DefaultTenantProfileConfiguration profile = tenantProfile.getDefaultProfileConfiguration(); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java index 8977591391..b517cda103 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/TbMqttTransportComponent.java @@ -17,10 +17,11 @@ package org.thingsboard.server.transport.mqtt; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import java.lang.annotation.Inherited; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; +@Inherited @Retention(RetentionPolicy.RUNTIME) @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.mqtt.enabled}'=='true')") -public @interface TbMqttTransportComponent { -} +public @interface TbMqttTransportComponent {} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java index fa06150b1a..91e7dedbf2 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewaySessionHandler.java @@ -315,6 +315,15 @@ public abstract class AbstractGatewaySessionHandler getDevices () { + return this.devices; + } + private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) { return new TransportServiceCallback() { @Override diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java index 1be6274edb..042ea129a4 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugDeviceSessionContext.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.mqtt.session; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -33,9 +34,10 @@ import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.stream.Collectors; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DCMD; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProto; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.getTsKvProtoFromJsonNode; @Slf4j public class SparkplugDeviceSessionContext extends AbstractGatewayDeviceSessionContext { @@ -51,27 +53,49 @@ public class SparkplugDeviceSessionContext extends AbstractGatewayDeviceSessionC super(parent, deviceInfo, deviceProfile, mqttQoSMap, transportService); } - public Map getDeviceBirthMetrics() { + public Map getDeviceBirthMetrics() { return deviceBirthMetrics; } - public void setDeviceBirthMetrics(java.util.List metrics) { - this.deviceBirthMetrics.putAll(metrics.stream() - .collect(Collectors.toMap(SparkplugBProto.Payload.Metric::getName, metric -> metric))); + public void setDeviceBirthMetrics(java.util.List metrics) { + for (var metric : metrics) { + if (metric.hasName()) { + this.deviceBirthMetrics.put(metric.getName(), metric); + } else { + throw new IllegalArgumentException("The metric name of device: '" + this.getDeviceInfo().getDeviceName() + "' must not be empty or null! Metric: [" + metric + "]"); + } + if (metric.hasAlias() && this.parent.getNodeAlias().putIfAbsent(metric.getAlias(), metric.getName()) != null) { + throw new DuplicateKeyException("The alias '" + metric.getAlias() + "' already exists in device: '" + this.getDeviceInfo().getDeviceName() + "'"); + } + } } @Override public void onAttributeUpdate(UUID sessionId, TransportProtos.AttributeUpdateNotificationMsg notification) { log.trace("[{}] Received attributes update notification to sparkplug device", sessionId); - notification.getSharedUpdatedList().forEach(tsKvProto -> { - if (getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { + notification.getSharedUpdatedList().forEach(tsKvProtoShared -> { + SparkplugMessageType messageType = DCMD; + TransportProtos.TsKvProto tsKvProto = tsKvProtoShared; + if ("JSON_V".equals(tsKvProtoShared.getKv().getType().name())) { + try { + messageType = SparkplugMessageType.parseMessageType(tsKvProtoShared.getKv().getKey()); + tsKvProto = getTsKvProtoFromJsonNode(JacksonUtil.toJsonNode(tsKvProtoShared.getKv().getJsonV()), tsKvProtoShared.getTs()); + } catch (ThingsboardException e) { + messageType = null; + log.error("Failed attributes update notification to sparkplug device [{}]. ", deviceInfo.getDeviceName(), e); + } + } + if (messageType != null && messageType.isSubscribe() && messageType.isDevice() + && getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { SparkplugTopic sparkplugTopic = new SparkplugTopic(parent.getSparkplugTopicNode(), - SparkplugMessageType.DCMD, deviceInfo.getDeviceName()); + messageType, deviceInfo.getDeviceName()); parent.createSparkplugMqttPublishMsg(tsKvProto, - sparkplugTopic.toString(), - getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) + sparkplugTopic.toString(), + getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) .ifPresent(this.parent::writeAndFlush); + } else { + log.trace("Failed attributes update notification to sparkplug device [{}]. ", deviceInfo.getDeviceName()); } }); } @@ -81,20 +105,22 @@ public class SparkplugDeviceSessionContext extends AbstractGatewayDeviceSessionC log.trace("[{}] Received RPC Request notification to sparkplug device", sessionId); try { SparkplugMessageType messageType = SparkplugMessageType.parseMessageType(rpcRequest.getMethodName()); - SparkplugRpcRequestHeader header = JacksonUtil.fromString(rpcRequest.getParams(), SparkplugRpcRequestHeader.class); - header.setMessageType(messageType.name()); - TransportProtos.TsKvProto tsKvProto = getTsKvProto(header.getMetricName(), header.getValue(), new Date().getTime()); - if (getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { - SparkplugTopic sparkplugTopic = new SparkplugTopic(parent.getSparkplugTopicNode(), - messageType, deviceInfo.getDeviceName()); - parent.createSparkplugMqttPublishMsg(tsKvProto, - sparkplugTopic.toString(), - getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) - .ifPresent(payload -> parent.sendToDeviceRpcRequest(payload, rpcRequest, sessionInfo)); - } else { - parent.sendErrorRpcResponse(sessionInfo, rpcRequest.getRequestId(), - ThingsboardErrorCode.BAD_REQUEST_PARAMS, " Failed send To Device Rpc Request: " + - rpcRequest.getMethodName() + ". This device does not have a metricName: [" + tsKvProto.getKv().getKey() + "]"); + if (messageType.isSubscribe()) { + SparkplugRpcRequestHeader header = JacksonUtil.fromString(rpcRequest.getParams(), SparkplugRpcRequestHeader.class); + header.setMessageType(messageType.name()); + TransportProtos.TsKvProto tsKvProto = getTsKvProto(header.getMetricName(), header.getValue(), new Date().getTime()); + if (getDeviceBirthMetrics().containsKey(tsKvProto.getKv().getKey())) { + SparkplugTopic sparkplugTopic = new SparkplugTopic(parent.getSparkplugTopicNode(), + messageType, deviceInfo.getDeviceName()); + parent.createSparkplugMqttPublishMsg(tsKvProto, + sparkplugTopic.toString(), + getDeviceBirthMetrics().get(tsKvProto.getKv().getKey())) + .ifPresent(payload -> parent.sendToDeviceRpcRequest(payload, rpcRequest, sessionInfo)); + } else { + parent.sendErrorRpcResponse(sessionInfo, rpcRequest.getRequestId(), + ThingsboardErrorCode.BAD_REQUEST_PARAMS, " Failed send To Device Rpc Request: " + + rpcRequest.getMethodName() + ". This device does not have a metricName: [" + tsKvProto.getKv().getKey() + "]"); + } } } catch (ThingsboardException e) { parent.sendErrorRpcResponse(sessionInfo, rpcRequest.getRequestId(), diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java index 217a1713ca..cdb562c063 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/SparkplugNodeSessionHandler.java @@ -18,19 +18,15 @@ package org.thingsboard.server.transport.mqtt.session; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; -import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; -import com.google.protobuf.Descriptors; import io.netty.handler.codec.mqtt.MqttMessage; import io.netty.handler.codec.mqtt.MqttPublishMessage; -import io.netty.handler.codec.mqtt.MqttQoS; import io.netty.handler.codec.mqtt.MqttTopicSubscription; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.eclipse.leshan.core.ResponseCode; +import org.springframework.dao.DuplicateKeyException; import org.springframework.util.CollectionUtils; import org.thingsboard.server.common.adaptor.AdaptorException; -import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.adaptor.ProtoConverter; import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -41,7 +37,9 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto; import org.thingsboard.server.transport.mqtt.MqttTransportHandler; import org.thingsboard.server.transport.mqtt.util.sparkplug.MetricDataType; +import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType; import org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic; +import org.thingsboard.server.transport.mqtt.util.sparkplug.SpecVersion; import java.util.ArrayList; import java.util.List; @@ -50,26 +48,32 @@ import java.util.Optional; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugConnectionState.ONLINE; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.DBIRTH; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.NBIRTH; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.parseMessageType; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.SPARKPLUG_BD_SEQUENCE_NUMBER_KEY; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.SPARKPLUG_SEQUENCE_NUMBER_KEY; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.createMetric; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.fromSparkplugBMetricToKeyValueProto; import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMetricUtil.validatedValueByTypeMetric; -import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicUtil.parseTopicSubscribe; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_REGEXP; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_STATE_REGEXP; /** * Created by nickAS21 on 12.12.22 */ @Slf4j +@SpecVersion(spec = "sparkplug", version = "3.0.0") public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler { @Getter private final SparkplugTopic sparkplugTopicNode; @Getter private final Map nodeBirthMetrics; + @Getter + private final Map nodeAlias; private final MqttTransportHandler parent; public SparkplugNodeSessionHandler(MqttTransportHandler parent, DeviceSessionCtx deviceSessionCtx, UUID sessionId, @@ -78,23 +82,27 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler(); + this.nodeAlias = new ConcurrentHashMap<>(); } - public void setNodeBirthMetrics(java.util.List metrics) { - this.nodeBirthMetrics.putAll(metrics.stream() - .collect(Collectors.toMap(SparkplugBProto.Payload.Metric::getName, metric -> metric))); + public void setNodeBirthMetrics(java.util.List metrics) throws AdaptorException { + for (var metric : metrics) { + if (metric.hasName()) { + this.nodeBirthMetrics.put(metric.getName(), metric); + } else { + throw new AdaptorException("The metric name of edgeNode: '" + this.sparkplugTopicNode.getEdgeNodeId() + "' must not be empty or null! Metric: [" + metric + "]"); + } + if (metric.hasAlias() && this.nodeAlias.putIfAbsent(metric.getAlias(), metric.getName()) != null) { + throw new AdaptorException("The alias '" + metric.getAlias() + "' already exists in edgeNode: '" + this.sparkplugTopicNode.getEdgeNodeId() + "'"); + } + } } - public TransportProtos.PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; - byte[] bytes = getBytes(inbound.payload()); - Descriptors.Descriptor telemetryDynamicMsgDescriptor = ProtoConverter.validateDescriptor(deviceSessionCtx.getTelemetryDynamicMsgDescriptor()); - try { - return JsonConverter.convertToTelemetryProto(JsonParser.parseString(ProtoConverter.dynamicMsgToJson(bytes, telemetryDynamicMsgDescriptor))); - } catch (Exception e) { - log.debug("Failed to decode post telemetry request", e); - throw new AdaptorException(e); - } + + public boolean onValidateNDEATH(SparkplugBProto.Payload sparkplugBProto) throws ThingsboardException { + return sparkplugBProto.getMetricsCount() == 1 && SPARKPLUG_BD_SEQUENCE_NUMBER_KEY.equals(sparkplugBProto.getMetrics(0).getName()) + && this.nodeBirthMetrics.get(SPARKPLUG_BD_SEQUENCE_NUMBER_KEY) != null + && sparkplugBProto.getMetrics(0).getLongValue() == this.nodeBirthMetrics.get(SPARKPLUG_BD_SEQUENCE_NUMBER_KEY).getLongValue(); } public void onAttributesTelemetryProto(int msgId, SparkplugBProto.Payload sparkplugBProto, SparkplugTopic topic) throws AdaptorException, ThingsboardException { @@ -115,7 +123,11 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler grantedQoSList, MqttTopicSubscription subscription, - MqttQoS reqQoS) throws ThingsboardException { - SparkplugTopic sparkplugTopic = parseTopicSubscribe(subscription.topicFilter()); - if (sparkplugTopic.getGroupId() == null) { - // TODO SUBSCRIBE NameSpace - } else if (sparkplugTopic.getType() == null) { - // TODO SUBSCRIBE GroupId - } else if (sparkplugTopic.isNode()) { - // SUBSCRIBE Node - parent.processAttributesRpcSubscribeSparkplugNode(grantedQoSList, reqQoS); + /** + * Subscribe: spBv1.0/STATE/my_primary_hos -> Implemented as status via checkSparkplugNodeSession + * Subscribe: CMD/DATA -> Implemented after connection: SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG/SUBSCRIBE_TO_RPC_ASYNC_MSG + * @param subscription + * @throws ThingsboardException + */ + public void handleSparkplugSubscribeMsg(MqttTopicSubscription subscription) throws ThingsboardException { + String topic = subscription.topicFilter(); + if (topic != null && topic.startsWith(TOPIC_STATE_REGEXP)) { + log.trace("Subscribing on it’s own spBv1.0/STATE/[the Sparkplug Host Application] - Implemented as status via checkSparkplugNodeSession"); + } else if (this.validateTopicDataSubscribe(topic)) { + // TODO if need subscription DATA + log.trace("Subscribing on it’s own [" + topic + "] - Implemented as SUBSCRIBE_TO_ATTRIBUTE_UPDATES_ASYNC_MSG/SUBSCRIBE_TO_RPC_ASYNC_MSG via checkSparkplugNode/DeviceSession"); } else { - // SUBSCRIBE Device - DO NOTHING, WE HAVE ALREADY SUBSCRIBED. - // TODO: track that node subscribed to # or to particular device. + log.trace("Failed to subscribe to the topic: [" + topic + "]."); } } @@ -192,7 +206,8 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler onDeviceConnectProto(SparkplugTopic topic) throws ThingsboardException { + private ListenableFuture onDeviceConnectProto(SparkplugTopic topic) throws + ThingsboardException { try { String deviceType = this.gateway.getDeviceType() + " device"; return onDeviceConnect(topic.getNodeDeviceName(), deviceType); @@ -202,22 +217,45 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler convertToPostTelemetry(SparkplugBProto.Payload sparkplugBProto, Set attributesMetricNames, String topicTypeName) throws AdaptorException { + /** + * Sparkplug 3.0.0 -> 6.4.6. Metric + * https://sparkplug.eclipse.org/specification/version/3.0/documents/sparkplug-specification-3.0.0.pdf#%5B%7B%22num%22%3A339%2C%22gen%22%3A0%7D%2C%7B%22name%22%3A%22XYZ%22%7D%2C0%2C455.52%2Cnull%5D + * [tck-id-payloads-name-requirement] The name MUST be included with every metric unless aliases are being used. ◦ All UTF-8 characters are allowed in the metric name. However, special characters including but not limited to the following are discouraged: . , \ @ # $ % ^ & * ( ) [ ] { } | ! ` ~ : ; ' " < > ?. This is because many Sparkplug Host Applications may have issues handling them. + * • alias (are optional and not required): + * - This is an unsigned 64-bit integer representing an optional alias for a Sparkplug B payload. + * - If aliases are used, the following rules apply: + * -- [tck-id-payloads-alias-uniqueness] If supplied in an NBIRTH or BIRTH it MUST be a unique number across this Edge Node’s entire set of metrics. + * -- no two metrics for the same Edge Node can have the same alias. + * -- [tck-id-payloads-alias-birth-requirement] NBIRTH and DBIRTH messages MUST include both a metric name and alias. + * -- [tck-id-payloads-alias-data-cmd-requirement] NDATA, DDATA, NCMD, and DCMD messages MUST only include an alias and the metric name MUST be excluded. + * @param sparkplugBProto + * @param attributesMetricNames + * @param topicTypeName + * @return + * @throws AdaptorException + */ + private List convertToPostTelemetry(SparkplugBProto.Payload + sparkplugBProto, Set attributesMetricNames, String topicTypeName) throws AdaptorException { try { List msgs = new ArrayList<>(); for (SparkplugBProto.Payload.Metric protoMetric : sparkplugBProto.getMetricsList()) { - if (attributesMetricNames == null || !matches(attributesMetricNames, protoMetric)) { - long ts = protoMetric.getTimestamp(); - String key = "bdSeq".equals(protoMetric.getName()) ? - topicTypeName + " " + protoMetric.getName() : protoMetric.getName(); - Optional keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(key, protoMetric); - keyValueProtoOpt.ifPresent(kvProto -> msgs.add(postTelemetryMsgCreated(kvProto, ts))); + String metricName = protoMetric.hasName() ? protoMetric.getName() : protoMetric.hasAlias() ? this.nodeAlias.get(protoMetric.getAlias()) : null; + if (metricName == null) { + throw new ThingsboardException("Metric without metricName and alias.", ThingsboardErrorCode.INVALID_ARGUMENTS); + } else { + if (attributesMetricNames == null || !matches(attributesMetricNames, metricName)) { + long ts = protoMetric.getTimestamp(); + String key = SPARKPLUG_BD_SEQUENCE_NUMBER_KEY.equals(protoMetric.getName()) ? + topicTypeName + " " + protoMetric.getName() : protoMetric.getName(); + Optional keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(key, protoMetric); + keyValueProtoOpt.ifPresent(kvProto -> msgs.add(postTelemetryMsgCreated(kvProto, ts))); + } } } if (DBIRTH.name().equals(topicTypeName)) { TransportProtos.KeyValueProto.Builder keyValueProtoBuilder = TransportProtos.KeyValueProto.newBuilder(); - keyValueProtoBuilder.setKey(topicTypeName + " " + "seq"); + keyValueProtoBuilder.setKey(topicTypeName + " " + SPARKPLUG_SEQUENCE_NUMBER_KEY); keyValueProtoBuilder.setType(TransportProtos.KeyValueType.LONG_V); keyValueProtoBuilder.setLongV(sparkplugBProto.getSeq()); msgs.add(postTelemetryMsgCreated(keyValueProtoBuilder.build(), sparkplugBProto.getTimestamp())); @@ -235,13 +273,18 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler msgs = new ArrayList<>(); for (SparkplugBProto.Payload.Metric protoMetric : sparkplugBProto.getMetricsList()) { - if (matches(attributesMetricNames, protoMetric)) { - TransportApiProtos.AttributesMsg.Builder deviceAttributesMsgBuilder = TransportApiProtos.AttributesMsg.newBuilder(); - Optional msgOpt = getPostAttributeMsg(protoMetric); - if (msgOpt.isPresent()) { - deviceAttributesMsgBuilder.setDeviceName(deviceName); - deviceAttributesMsgBuilder.setMsg(msgOpt.get()); - msgs.add(deviceAttributesMsgBuilder.build()); + String metricName = protoMetric.hasName() ? protoMetric.getName() : protoMetric.hasAlias() ? this.nodeAlias.get(protoMetric.getAlias()) : null; + if (metricName == null) { + throw new ThingsboardException("Metric without metricName and alias.", ThingsboardErrorCode.INVALID_ARGUMENTS); + } else { + if (matches(attributesMetricNames, metricName)) { + TransportApiProtos.AttributesMsg.Builder deviceAttributesMsgBuilder = TransportApiProtos.AttributesMsg.newBuilder(); + Optional msgOpt = getPostAttributeMsg(protoMetric); + if (msgOpt.isPresent()) { + deviceAttributesMsgBuilder.setDeviceName(deviceName); + deviceAttributesMsgBuilder.setMsg(msgOpt.get()); + msgs.add(deviceAttributesMsgBuilder.build()); + } } } } @@ -252,8 +295,7 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler attributesMetricNames, SparkplugBProto.Payload.Metric protoMetric) { - String metricName = protoMetric.getName(); + private boolean matches(Set attributesMetricNames, String metricName) { for (String attributeMetricFilter : attributesMetricNames) { if (metricName.equals(attributeMetricFilter) || (attributeMetricFilter.endsWith("*") && metricName.startsWith( @@ -264,7 +306,8 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler getPostAttributeMsg(SparkplugBProto.Payload.Metric protoMetric) throws ThingsboardException { + private Optional getPostAttributeMsg(SparkplugBProto.Payload.Metric + protoMetric) throws ThingsboardException { Optional keyValueProtoOpt = fromSparkplugBMetricToKeyValueProto(protoMetric.getName(), protoMetric); if (keyValueProtoOpt.isPresent()) { TransportProtos.PostAttributeMsg.Builder builder = TransportProtos.PostAttributeMsg.newBuilder(); @@ -285,7 +328,9 @@ public class SparkplugNodeSessionHandler extends AbstractGatewaySessionHandler= 4 && splitTopic.length <= 5 && + splitTopic[0].equals(this.sparkplugTopicNode.getNamespace()) && + splitTopic[1].equals(this.sparkplugTopicNode.getGroupId()) && + splitTopic[3].equals(this.sparkplugTopicNode.getEdgeNodeId())) { + SparkplugMessageType messageType = parseMessageType(splitTopic[2]); + return messageType.isData(); + } + return false; } - } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/DeviceDescriptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/DeviceDescriptor.java new file mode 100644 index 0000000000..cf684884f3 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/DeviceDescriptor.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.util.sparkplug; + +public class DeviceDescriptor extends EdgeNodeDescriptor { + + private final String deviceId; + private final String descriptorString; + + public DeviceDescriptor(String groupId, String edgeNodeId, String deviceId) { + super(groupId, edgeNodeId); + this.deviceId = deviceId; + this.descriptorString = groupId + "/" + edgeNodeId + "/" + deviceId; + } + + public DeviceDescriptor(String descriptorString) { + super(descriptorString.substring(0, descriptorString.lastIndexOf("/"))); + this.deviceId = descriptorString.substring(descriptorString.lastIndexOf("/") + 1); + this.descriptorString = descriptorString; + } + + public DeviceDescriptor(EdgeNodeDescriptor edgeNodeDescriptor, String deviceId) { + super(edgeNodeDescriptor.getGroupId(), edgeNodeDescriptor.getEdgeNodeId()); + this.deviceId = deviceId; + this.descriptorString = edgeNodeDescriptor.getDescriptorString() + "/" + deviceId; + } + + public String getDeviceId() { + return deviceId; + } + + /** + * Returns a {@link String} representing the Device's Descriptor of the form: + * "//". + * + * @return a {@link String} representing the Device's Descriptor. + */ + @Override + public String getDescriptorString() { + return descriptorString; + } + + public String getEdgeNodeDescriptorString() { + return super.getDescriptorString(); + } + + @Override + public int hashCode() { + return this.getDescriptorString().hashCode(); + } + + @Override + public boolean equals(Object object) { + if (object instanceof DeviceDescriptor) { + return this.getDescriptorString().equals(((DeviceDescriptor) object).getDescriptorString()); + } + return this.getDescriptorString().equals(object); + } + + @Override + public String toString() { + return getDescriptorString(); + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/EdgeNodeDescriptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/EdgeNodeDescriptor.java new file mode 100644 index 0000000000..c8ea2c457e --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/EdgeNodeDescriptor.java @@ -0,0 +1,80 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.util.sparkplug; + +import com.fasterxml.jackson.annotation.JsonValue; + +public class EdgeNodeDescriptor implements SparkplugDescriptor{ + + private final String groupId; + private final String edgeNodeId; + private final String descriptorString; + + public EdgeNodeDescriptor(String groupId, String edgeNodeId) { + this.groupId = groupId; + this.edgeNodeId = edgeNodeId; + this.descriptorString = groupId + "/" + edgeNodeId; + } + + /** + * Creates and EdgeNodeDescriptor from a {@link String} of the form group_name/edge_node_name + * + * @param descriptorString the {@link String} representation of an EdgeNodeDescriptor + */ + public EdgeNodeDescriptor(String descriptorString) { + String[] tokens = descriptorString.split("/"); + this.groupId = tokens[0]; + this.edgeNodeId = tokens[1]; + this.descriptorString = descriptorString; + } + + public String getGroupId() { + return groupId; + } + + public String getEdgeNodeId() { + return edgeNodeId; + } + + /** + * Returns a {@link String} representing the Edge Node's Descriptor of the form: "/". + * + * @return a {@link String} representing the Edge Node's Descriptor. + */ + @Override + public String getDescriptorString() { + return descriptorString; + } + + @Override + public int hashCode() { + return this.getDescriptorString().hashCode(); + } + + @Override + public boolean equals(Object object) { + if (object instanceof EdgeNodeDescriptor) { + return this.getDescriptorString().equals(((EdgeNodeDescriptor) object).getDescriptorString()); + } + return this.getDescriptorString().equals(object); + } + + @Override + @JsonValue + public String toString() { + return getDescriptorString(); + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java index c500d5ddd6..fd0bfb98cf 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/MetricDataType.java @@ -52,7 +52,24 @@ public enum MetricDataType { File(18, SparkplugMetricUtil.File.class), Template(19, SparkplugBProto.Payload.Template.class), - // PropertyValue Types (20 and 21) are NOT metric datatypes + // Additional PropertyValue Types (PropertyValue Types (20 and 21) are NOT metric datatypes) + PropertySet(20, SparkplugBProto.Payload.PropertySet.class), + PropertySetList(21, SparkplugBProto.Payload.PropertySetList.class), + + // Array Types + Int8Array(22, Byte[].class), + Int16Array(23, Short[].class), + Int32Array(24, Integer[].class), + Int64Array(25, Long[].class), + UInt8Array(26, Short[].class), + UInt16Array(27, Integer[].class), + UInt32Array(28, Long[].class), + UInt64Array(29, BigInteger[].class), + FloatArray(30, Float[].class), + DoubleArray(31, Double[].class), + BooleanArray(32, Boolean[].class), + StringArray(33, String[].class), + DateTimeArray(34, Date[].class), // Unknown Unknown(0, Object.class); @@ -140,6 +157,36 @@ public enum MetricDataType { return File; case 19: return Template; + case 20: + return PropertySet; + case 21: + return PropertySetList; + case 22: + return Int8Array; + case 23: + return Int16Array; + case 24: + return Int32Array; + case 25: + return Int64Array; + case 26: + return UInt8Array; + case 27: + return UInt16Array; + case 28: + return UInt32Array; + case 29: + return UInt64Array; + case 30: + return FloatArray; + case 31: + return DoubleArray; + case 32: + return BooleanArray; + case 33: + return StringArray; + case 34: + return DateTimeArray; default: return Unknown; } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugDescriptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugDescriptor.java new file mode 100644 index 0000000000..9deff97bf4 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugDescriptor.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.util.sparkplug; + +public interface SparkplugDescriptor { + + /** + * Returns the String representation of this {@link SparkplugDescriptor} + * + * @return the String representation of this {@link SparkplugDescriptor} + */ + public String getDescriptorString(); +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java index deb04034f2..f5b0929a46 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMessageType.java @@ -91,10 +91,14 @@ public enum SparkplugMessageType { return STATE.equals(type) ? "sparkplugConnectionState" : type.name(); } + public boolean isState() { + return this.equals(STATE); + } + public boolean isDeath() { return this.equals(DDEATH) || this.equals(NDEATH); } - + public boolean isCommand() { return this.equals(DCMD) || this.equals(NCMD); } @@ -110,4 +114,19 @@ public enum SparkplugMessageType { public boolean isRecord() { return this.equals(DRECORD) || this.equals(NRECORD); } + public boolean isSubscribe() { + return isCommand() || isData() || isRecord(); + } + + public boolean isNode() { + return this.equals(NBIRTH) + || this.equals(NCMD) || this.equals(NDATA) + ||this.equals(NDEATH) || this.equals(NRECORD); + } + public boolean isDevice() { + return this.equals(DBIRTH) + || this.equals(DCMD) || this.equals(DDATA) + ||this.equals(DDEATH) || this.equals(DRECORD); + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java index a6a071a740..f005235d53 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugMetricUtil.java @@ -29,6 +29,8 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto; +import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto.Payload.Metric; +import org.thingsboard.server.gen.transport.mqtt.SparkplugBProto.Payload.Metric.Builder; import java.math.BigDecimal; import java.nio.ByteBuffer; @@ -45,6 +47,9 @@ import static org.thingsboard.common.util.JacksonUtil.newArrayNode; @Slf4j public class SparkplugMetricUtil { + public static final String SPARKPLUG_SEQUENCE_NUMBER_KEY = "seq"; + public static final String SPARKPLUG_BD_SEQUENCE_NUMBER_KEY = "bdSeq"; + public static Optional fromSparkplugBMetricToKeyValueProto(String key, SparkplugBProto.Payload.Metric protoMetric) throws ThingsboardException { // Check if the null flag has been set indicating that the value is null if (protoMetric.getIsNull()) { @@ -141,13 +146,20 @@ public class SparkplugMetricUtil { return Optional.empty(); } } + public static SparkplugBProto.Payload.Metric createMetric(Object value, long ts, String key, MetricDataType metricDataType, Long alias) throws ThingsboardException { + Builder metric = Metric.newBuilder(); + metric.setTimestamp(ts) + .setDatatype(metricDataType.toIntValue()); + if (alias >= 0) { + metric.setAlias(alias); + } + if (StringUtils.isNotBlank(key)) { + metric.setName(key); + } + return addToMetricValue(value, metric.build(), metricDataType); + } - public static SparkplugBProto.Payload.Metric createMetric(Object value, long ts, String key, MetricDataType metricDataType) throws ThingsboardException { - SparkplugBProto.Payload.Metric metric = SparkplugBProto.Payload.Metric.newBuilder() - .setTimestamp(ts) - .setName(key) - .setDatatype(metricDataType.toIntValue()) - .build(); + public static SparkplugBProto.Payload.Metric addToMetricValue(Object value, SparkplugBProto.Payload.Metric metric, MetricDataType metricDataType) throws ThingsboardException { switch (metricDataType) { case Int8: // (byte) return metric.toBuilder().setIntValue(((Byte) value).intValue()).build(); @@ -189,6 +201,12 @@ public class SparkplugMetricUtil { return metric; } + public static TransportProtos.TsKvProto getTsKvProtoFromJsonNode(JsonNode kvProto, long ts) throws ThingsboardException { + String kvProtoKey = kvProto.fieldNames().next(); + String kvProtoValue = kvProto.get(kvProtoKey).asText(); + return getTsKvProto(kvProtoKey, kvProtoValue, ts); + } + public static TransportProtos.TsKvProto getTsKvProto(String key, Object value, long ts) throws ThingsboardException { try { TransportProtos.TsKvProto.Builder tsKvProtoBuilder = TransportProtos.TsKvProto.newBuilder(); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java index 278ea52ccb..6901cf8078 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopic.java @@ -15,65 +15,129 @@ */ package org.thingsboard.server.transport.mqtt.util.sparkplug; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; + +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.parseMessageType; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_ROOT_SPB_V_1_0; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopicService.TOPIC_SPLIT_REGEXP; /** * Created by nickAS21 on 12.12.22 + * A Sparkplug MQTT Topic */ @JsonInclude(JsonInclude.Include.NON_NULL) public class SparkplugTopic { /** * The Sparkplug namespace version. - * For the Sparkplug™ B version of the specification, the UTF-8 string constant for the namespace element will be: “spBv1.0” */ - private String namespace; + private final String namespace; + + /** + * The SparkplugDesciptor for this Edge Node or Device + */ + @JsonIgnore + private final SparkplugDescriptor sparkplugDescriptor; + + /** + * The {@link EdgeNodeDescriptor} for this Edge Node or Device + */ + private final EdgeNodeDescriptor edgeNodeDescriptor; /** * The ID of the logical grouping of Edge of Network (EoN) Nodes and devices. */ - private String groupId; + private final String groupId; /** * The ID of the Edge of Network (EoN) Node. */ - private String edgeNodeId; + private final String edgeNodeId; /** * The ID of the device. */ private String deviceId; + /** + * The ID if this is a Sparkplug Host Application topic + */ + private final String hostApplicationId; + /** * The message type. */ - private SparkplugMessageType type; + private final SparkplugMessageType type; + + public SparkplugTopic() { + this.namespace = null; + this.sparkplugDescriptor = null; + this.edgeNodeDescriptor = null; + this.groupId = null; + this.edgeNodeId = null; + this.deviceId = null; + this.hostApplicationId = null; + this.type = null; + } + + public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type) { + super(); + this.namespace = sparkplugTopic.namespace; + this.groupId = sparkplugTopic.groupId; + this.edgeNodeId = sparkplugTopic.edgeNodeId; + this.sparkplugDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.deviceId = null; + this.type = type; + this.hostApplicationId = null; + } - /** - * Constructor (device). + public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type, String deviceId) { + super(); + this.namespace = sparkplugTopic.namespace; + this.groupId = sparkplugTopic.groupId; + this.edgeNodeId = sparkplugTopic.edgeNodeId; + this.deviceId = deviceId; + this.sparkplugDescriptor = deviceId == null + ? new EdgeNodeDescriptor(groupId, edgeNodeId) + : new DeviceDescriptor(groupId, edgeNodeId, deviceId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.type = type; + this.hostApplicationId = null; + } + + /** + * A Constructor for Device Topics * - * @param namespace the namespace. - * @param groupId the group ID. - * @param edgeNodeId the edge node ID. - * @param deviceId the device ID. - * @param type the message type. + * @param namespace the namespace + * @param groupId the Group ID + * @param edgeNodeId the Edge Node ID + * @param deviceId the Device ID + * @param type the message type */ public SparkplugTopic(String namespace, String groupId, String edgeNodeId, String deviceId, SparkplugMessageType type) { super(); this.namespace = namespace; + this.sparkplugDescriptor = deviceId == null + ? new EdgeNodeDescriptor(groupId, edgeNodeId) + : new DeviceDescriptor(groupId, edgeNodeId, deviceId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); this.groupId = groupId; this.edgeNodeId = edgeNodeId; this.deviceId = deviceId; + this.hostApplicationId = null; this.type = type; } /** - * Constructor (node). - * - * @param namespace the namespace. - * @param groupId the group ID. - * @param edgeNodeId the edge node ID. - * @param type the message type. + * A Constructor for Edge Node Topics + * @param namespace the namespace + * @param groupId the group ID + * @param edgeNodeId the edge node ID + * @param type the message type */ public SparkplugTopic(String namespace, String groupId, String edgeNodeId, SparkplugMessageType type) { super(); @@ -81,33 +145,110 @@ public class SparkplugTopic { this.groupId = groupId; this.edgeNodeId = edgeNodeId; this.deviceId = null; + this.sparkplugDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.edgeNodeDescriptor = new EdgeNodeDescriptor(groupId, edgeNodeId); + this.hostApplicationId = null; this.type = type; } - public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type) { - super(); - this.namespace = sparkplugTopic.namespace; - this.groupId = sparkplugTopic.groupId; - this.edgeNodeId = sparkplugTopic.edgeNodeId; - this.deviceId = null; - this.type = type; + /** + * A Constructor for Device Topics + * + * @param namespace the namespace + * @param deviceDescriptor the {@link EdgeNodeDescriptor} + * @param type the message type + */ + public SparkplugTopic(String namespace, DeviceDescriptor deviceDescriptor, SparkplugMessageType type) { + this(namespace, deviceDescriptor.getGroupId(), deviceDescriptor.getEdgeNodeId(), deviceDescriptor.getDeviceId(), + type); + } + + /** + * A Constructor for Edge Node Topics + * + * @param namespace the namespace + * @param edgeNodeDescriptor the {@link EdgeNodeDescriptor} + * @param type the message type + */ + public SparkplugTopic(String namespace, EdgeNodeDescriptor edgeNodeDescriptor, SparkplugMessageType type) { + this(namespace, edgeNodeDescriptor.getGroupId(), edgeNodeDescriptor.getEdgeNodeId(), type); } - public SparkplugTopic(SparkplugTopic sparkplugTopic, SparkplugMessageType type, String deviceId) { + + /** + * A Constructor for Host Application Topics + * + * @param namespace the namespace + * @param hostApplicationId the Host Application ID + */ + public SparkplugTopic(String namespace, String hostApplicationId, SparkplugMessageType type) { super(); - this.namespace = sparkplugTopic.namespace; - this.groupId = sparkplugTopic.groupId; - this.edgeNodeId = sparkplugTopic.edgeNodeId; - this.deviceId = deviceId; + this.namespace = namespace; + this.hostApplicationId = hostApplicationId; this.type = type; + this.sparkplugDescriptor = null; + this.edgeNodeDescriptor = null; + this.groupId = null; + this.edgeNodeId = null; + this.deviceId = null; + } + + public static SparkplugTopic parseTopic(String topicString) throws ThingsboardException { + try { + if (isValidIdElementToUTF8(topicString)) { + SparkplugMessageType messageType; + String[] splitTopic = topicString.split(TOPIC_SPLIT_REGEXP); + if (TOPIC_ROOT_SPB_V_1_0.equals(splitTopic[0])) { + if (splitTopic.length == 3) { + messageType = parseMessageType(splitTopic[1]); + if (messageType.isState()) + return new SparkplugTopic(TOPIC_ROOT_SPB_V_1_0, splitTopic[2], messageType); + } else if (splitTopic.length == 4) { + messageType = parseMessageType(splitTopic[2]); + if (messageType.isNode()) + return new SparkplugTopic(TOPIC_ROOT_SPB_V_1_0, splitTopic[1], splitTopic[3], messageType); + } else if (splitTopic.length == 5) { + messageType = parseMessageType(splitTopic[2]); + if (messageType.isDevice()) + return new SparkplugTopic(TOPIC_ROOT_SPB_V_1_0, splitTopic[1], splitTopic[3], splitTopic[4], messageType); + + } + } + } + throw new ThingsboardException("Invalid Sparkplug topic from String: " + topicString, ThingsboardErrorCode.INVALID_ARGUMENTS); + } catch ( + Exception e) { + throw new ThingsboardException(e, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } + } /** - * @return the Sparkplug namespace version + * Returns the Sparkplug namespace version. + * + * @return the namespace */ public String getNamespace() { return namespace; } + /** + * Returns the {@link SparkplugDescriptor} + * + * @return the SparkplugDescriptor + */ + public SparkplugDescriptor getSparkplugDescriptor() { + return sparkplugDescriptor; + } + + /** + * Returns the {@link EdgeNodeDescriptor} + * + * @return the EdgeNodeDescriptor + */ + public EdgeNodeDescriptor getEdgeNodeDescriptor() { + return edgeNodeDescriptor; + } + /** * Returns the ID of the logical grouping of Edge of Network (EoN) Nodes and devices. * @@ -118,20 +259,39 @@ public class SparkplugTopic { } /** - * @return the ID of the Edge of Network (EoN) Node + * Returns the ID of the Edge of Network (EoN) Node. + * + * @return the edge node ID */ public String getEdgeNodeId() { return edgeNodeId; } /** + * Returns the ID of the device. + * * @return the device ID */ public String getDeviceId() { return deviceId; } + public void updateDeviceIdPlus(String deviceIdNew) { + this.deviceId = this.deviceId.equals("+") ? deviceIdNew : this.deviceId; + } + + /** + * Returns the Host Application ID if this is a Host topic + * + * @return the Host Application ID + */ + public String getHostApplicationId() { + return hostApplicationId; + } + /** + * Returns the message type. + * * @return the message type */ public SparkplugMessageType getType() { @@ -140,12 +300,15 @@ public class SparkplugTopic { @Override public String toString() { - StringBuilder sb = new StringBuilder(getNamespace()).append("/") - .append(getGroupId()).append("/") - .append(getType()).append("/") - .append(getEdgeNodeId()); - if (getDeviceId() != null) { - sb.append("/").append(getDeviceId()); + StringBuilder sb = new StringBuilder(); + if (hostApplicationId == null) { + sb.append(getNamespace()).append("/").append(getGroupId()).append("/").append(getType()).append("/") + .append(getEdgeNodeId()); + if (getDeviceId() != null) { + sb.append("/").append(getDeviceId()); + } + } else { + sb.append(getNamespace()).append("/").append(getType()).append("/").append(hostApplicationId); } return sb.toString(); } @@ -165,5 +328,13 @@ public class SparkplugTopic { public String getNodeDeviceName() { return isNode() ? edgeNodeId : deviceId; } + + public static boolean isValidIdElementToUTF8(String deviceIdElement) { + if (deviceIdElement == null) { + return false; + } + String regex = "^(?!.*//)[^+#]*$"; + return deviceIdElement.matches(regex); + } } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java new file mode 100644 index 0000000000..a6e2e34553 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicService.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.util.sparkplug; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.transport.mqtt.TbMqttTransportComponent; + +import java.util.HashMap; +import java.util.Map; + +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugMessageType.STATE; +import static org.thingsboard.server.transport.mqtt.util.sparkplug.SparkplugTopic.parseTopic; + +@Slf4j +@Service +@TbMqttTransportComponent +public class SparkplugTopicService { + + private static final Map SPLIT_TOPIC_CACHE = new HashMap<>(); + public static final String TOPIC_ROOT_SPB_V_1_0 = "spBv1.0"; + public static final String TOPIC_ROOT_CERT_SP = "$sparkplug/certificates/"; + public static final String TOPIC_SPLIT_REGEXP = "/"; + public static final String TOPIC_STATE_REGEXP = TOPIC_ROOT_SPB_V_1_0 + TOPIC_SPLIT_REGEXP + STATE.name() + TOPIC_SPLIT_REGEXP; + + public static SparkplugTopic getSplitTopic(String topic) throws ThingsboardException { + SparkplugTopic sparkplugTopic = SPLIT_TOPIC_CACHE.get(topic); + if (sparkplugTopic == null) { + // validation topic + sparkplugTopic = parseTopic(topic); + SPLIT_TOPIC_CACHE.put(topic, sparkplugTopic); + } + return sparkplugTopic; + } + + /** + * all ID Element MUST be a UTF-8 string + * and with the exception of the reserved characters of + (plus), / (forward slash). + * Publish: $sparkplug/certificates/spBv1.0/G1/NBIRTH/E1 + * Publish: spBv1.0/G1/NBIRTH/E1 + * Publish: $sparkplug/certificates/spBv1.0/G1/DBIRTH/E1/D1 + * Publish: spBv1.0/G1/DBIRTH/E1/D1 + * @param topic + * @return + * @throws ThingsboardException + */ + public static SparkplugTopic parseTopicPublish(String topic) throws ThingsboardException { + topic = topic.startsWith(TOPIC_ROOT_CERT_SP) ? topic.substring(TOPIC_ROOT_CERT_SP.length()) : topic; + topic = topic.indexOf("+") > 0 ? topic.substring(0, topic.indexOf("+")): topic; + return getSplitTopic(topic); + } +} + diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicUtil.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicUtil.java deleted file mode 100644 index aaaedcda60..0000000000 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SparkplugTopicUtil.java +++ /dev/null @@ -1,116 +0,0 @@ -/** - * Copyright © 2016-2025 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.util.sparkplug; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; -import org.thingsboard.server.common.data.exception.ThingsboardException; - -import java.util.HashMap; -import java.util.Map; - -/** - * Provides utility methods for handling Sparkplug MQTT message topics. - */ -public class SparkplugTopicUtil { - - private static final Map SPLIT_TOPIC_CACHE = new HashMap(); - private static final String TOPIC_INVALID_NUMBER = "Invalid number of topic elements: "; - public static final String NAMESPACE = "spBv1.0"; - - public static String[] getSplitTopic(String topic) { - String[] splitTopic = SPLIT_TOPIC_CACHE.get(topic); - if (splitTopic == null) { - splitTopic = topic.split("/"); - SPLIT_TOPIC_CACHE.put(topic, splitTopic); - } - - return splitTopic; - } - - /** - * Serializes a {@link SparkplugTopic} instance in to a JSON string. - * - * @param topic a {@link SparkplugTopic} instance - * @return a JSON string - * @throws JsonProcessingException - */ - public static String sparkplugTopicToString(SparkplugTopic topic) throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); - return mapper.writeValueAsString(topic); - } - - /** - * Parses a Sparkplug MQTT message topic string and returns a {@link SparkplugTopic} instance. - * - * @param topic a topic string - * @return a {@link SparkplugTopic} instance - * @throws ThingsboardException if an error occurs while parsing - */ - public static SparkplugTopic parseTopicSubscribe(String topic) throws ThingsboardException { - // TODO "+", "$" - topic = topic.indexOf("#") > 0 ? topic.substring(0, topic.indexOf("#")) : topic; - return parseTopic(SparkplugTopicUtil.getSplitTopic(topic)); - } - - public static SparkplugTopic parseTopicPublish(String topic) throws ThingsboardException { - if (topic.contains("#") || topic.contains("$") || topic.contains("+")) { - throw new ThingsboardException("Invalid of topic elements for Publish", ThingsboardErrorCode.INVALID_ARGUMENTS); - } else { - String[] splitTopic = SparkplugTopicUtil.getSplitTopic(topic); - if (splitTopic.length < 4 || splitTopic.length > 5) { - throw new ThingsboardException(TOPIC_INVALID_NUMBER + splitTopic.length, ThingsboardErrorCode.INVALID_ARGUMENTS); - } - return parseTopic(splitTopic); - } - } - - /** - * Parses a Sparkplug MQTT message topic string and returns a {@link SparkplugTopic} instance. - * - * @param splitTopic a topic split into tokens - * @return a {@link SparkplugTopic} instance - * @throws Exception if an error occurs while parsing - */ - @SuppressWarnings("incomplete-switch") - public static SparkplugTopic parseTopic(String[] splitTopic) throws ThingsboardException { - int length = splitTopic.length; - if (length == 0) { - throw new ThingsboardException(TOPIC_INVALID_NUMBER + length, ThingsboardErrorCode.INVALID_ARGUMENTS); - } else { - SparkplugMessageType type; - String namespace, edgeNodeId, groupId, deviceId; - namespace = validateNameSpace(splitTopic[0]); - groupId = length > 1 ? splitTopic[1] : null; - type = length > 2 ? SparkplugMessageType.parseMessageType(splitTopic[2]) : null; - edgeNodeId = length > 3 ? splitTopic[3] : null; - deviceId = length > 4 ? splitTopic[4] : null; - return new SparkplugTopic(namespace, groupId, edgeNodeId, deviceId, type); - } - } - - /** - * For the Sparkplug™ B version of the specification, the UTF-8 string constant for the namespace element will be: "spBv1.0" - * @param nameSpace - * @return - */ - private static String validateNameSpace(String nameSpace) throws ThingsboardException { - if (NAMESPACE.equals(nameSpace)) return nameSpace; - throw new ThingsboardException("The namespace [" + nameSpace + "] is not valid and must be [" + NAMESPACE + "] for the Sparkplug™ B version.", ThingsboardErrorCode.INVALID_ARGUMENTS); - } - -} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SpecVersion.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SpecVersion.java new file mode 100644 index 0000000000..07f5cbf53f --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/sparkplug/SpecVersion.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.util.sparkplug; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Target; + +@Documented +@Target(ElementType.TYPE) +public @interface SpecVersion { + String spec() default ""; + String version(); +} \ No newline at end of file diff --git a/common/transport/mqtt/src/main/proto/sparkplug.proto b/common/transport/mqtt/src/main/proto/sparkplug.proto index 8b5bc6fd22..c28a4e6410 100644 --- a/common/transport/mqtt/src/main/proto/sparkplug.proto +++ b/common/transport/mqtt/src/main/proto/sparkplug.proto @@ -21,51 +21,71 @@ import "google/protobuf/any.proto"; option java_package = "org.thingsboard.server.gen.transport.mqtt"; option java_outer_classname = "SparkplugBProto"; + +enum DataType { + // Indexes of Data Types + + // Unknown placeholder for future expansion. + Unknown = 0; + + // Basic Types + Int8 = 1; + Int16 = 2; + Int32 = 3; + Int64 = 4; + UInt8 = 5; + UInt16 = 6; + UInt32 = 7; + UInt64 = 8; + Float = 9; + Double = 10; + Boolean = 11; + String = 12; + DateTime = 13; + Text = 14; + + // Additional Metric Types + UUID = 15; + DataSet = 16; + Bytes = 17; + File = 18; + Template = 19; + + // Additional PropertyValue Types + PropertySet = 20; + PropertySetList = 21; + + // Array Types + Int8Array = 22; + Int16Array = 23; + Int32Array = 24; + Int64Array = 25; + UInt8Array = 26; + UInt16Array = 27; + UInt32Array = 28; + UInt64Array = 29; + FloatArray = 30; + DoubleArray = 31; + BooleanArray = 32; + StringArray = 33; + DateTimeArray = 34; +} + message Payload { - /* - // Indexes of Data Types - // Unknown placeholder for future expansion. - Unknown = 0; - // Basic Types - Int8 = 1; - Int16 = 2; - Int32 = 3; - Int64 = 4; - UInt8 = 5; - UInt16 = 6; - UInt32 = 7; - UInt64 = 8; - Float = 9; - Double = 10; - Boolean = 11; - String = 12; - DateTime = 13; - Text = 14; - // Additional Metric Types - UUID = 15; - DataSet = 16; - Bytes = 17; - File = 18; - Template = 19; - - // Additional PropertyValue Types - PropertySet = 20; - PropertySetList = 21; - */ message Template { message Parameter { - optional string name = 1; - optional uint32 type = 2; + optional string name = 1; + optional uint32 type = 2; oneof value { - uint32 int_value = 3; - uint64 long_value = 4; - float float_value = 5; - double double_value = 6; - bool boolean_value = 7; - string string_value = 8; + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; ParameterValueExtension extension_value = 9; } @@ -74,12 +94,12 @@ message Payload { } } - optional string version = 1; // The version of the Template to prevent mismatches - repeated Metric metrics = 2; // Each metric is the name of the metric and the datatype of the member but does not contain a value - repeated Parameter parameters = 3; - optional string template_ref = 4; // Reference to a template if this is extending a Template or an instance - must exist if an instance - optional bool is_definition = 5; - google.protobuf.Any extensions = 6; + optional string version = 1; // The version of the Template to prevent mismatches + repeated Metric metrics = 2; // Each metric is the name of the metric and the datatype of the member but does not contain a value + repeated Parameter parameters = 3; + optional string template_ref = 4; // Reference to a template if this is extending a Template or an instance - must exist if an instance + optional bool is_definition = 5; + google.protobuf.Any extensions = 6; } message DataSet { @@ -87,118 +107,118 @@ message Payload { message DataSetValue { oneof value { - uint32 int_value = 1; - uint64 long_value = 2; - float float_value = 3; - double double_value = 4; - bool boolean_value = 5; - string string_value = 6; - DataSetValueExtension extension_value = 7; + uint32 int_value = 1; + uint64 long_value = 2; + float float_value = 3; + double double_value = 4; + bool boolean_value = 5; + string string_value = 6; + DataSetValueExtension extension_value = 7; } message DataSetValueExtension { - google.protobuf.Any extensions = 1; + google.protobuf.Any extensions = 1; } } message Row { - repeated DataSetValue elements = 1; - google.protobuf.Any extensions = 2; // For third party extensions + repeated DataSetValue elements = 1; + google.protobuf.Any extensions = 2; // For third party extensions } - optional uint64 num_of_columns = 1; - repeated string columns = 2; - repeated uint32 types = 3; - repeated Row rows = 4; - google.protobuf.Any extensions = 5; // For third party extensions + optional uint64 num_of_columns = 1; + repeated string columns = 2; + repeated uint32 types = 3; + repeated Row rows = 4; + google.protobuf.Any extensions = 5; // For third party extensions } message PropertyValue { - optional uint32 type = 1; - optional bool is_null = 2; + optional uint32 type = 1; + optional bool is_null = 2; oneof value { - uint32 int_value = 3; - uint64 long_value = 4; - float float_value = 5; - double double_value = 6; - bool boolean_value = 7; - string string_value = 8; - PropertySet propertyset_value = 9; - PropertySetList propertysets_value = 10; // List of Property Values - PropertyValueExtension extension_value = 11; + uint32 int_value = 3; + uint64 long_value = 4; + float float_value = 5; + double double_value = 6; + bool boolean_value = 7; + string string_value = 8; + PropertySet propertyset_value = 9; + PropertySetList propertysets_value = 10; // List of Property Values + PropertyValueExtension extension_value = 11; } message PropertyValueExtension { - google.protobuf.Any extensions = 1; + google.protobuf.Any extensions = 1; } } message PropertySet { - repeated string keys = 1; // Names of the properties - repeated PropertyValue values = 2; - google.protobuf.Any extensions = 3; + repeated string keys = 1; // Names of the properties + repeated PropertyValue values = 2; + google.protobuf.Any extensions = 3; } message PropertySetList { repeated PropertySet propertyset = 1; - google.protobuf.Any extensions = 2; + google.protobuf.Any extensions = 2; } message MetaData { // Bytes specific metadata - optional bool is_multi_part = 1; + optional bool is_multi_part = 1; // General metadata - optional string content_type = 2; // Content/Media type - optional uint64 size = 3; // File size, String size, Multi-part size, etc - optional uint64 seq = 4; // Sequence number for multi-part messages + optional string content_type = 2; // Content/Media type + optional uint64 size = 3; // File size, String size, Multi-part size, etc + optional uint64 seq = 4; // Sequence number for multi-part messages // File metadata - optional string file_name = 5; // File name - optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc) - optional string md5 = 7; // md5 of data + optional string file_name = 5; // File name + optional string file_type = 6; // File type (i.e. xml, json, txt, cpp, etc) + optional string md5 = 7; // md5 of data // Catchalls and future expansion - optional string description = 8; // Could be anything such as json or xml of custom properties - google.protobuf.Any extensions = 9; + optional string description = 8; // Could be anything such as json or xml of custom properties + google.protobuf.Any extensions = 9; } message Metric { - optional string name = 1; // Metric name - should only be included on birth - optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages - optional uint64 timestamp = 3; // Timestamp associated with data acquisition time - optional uint32 datatype = 4; // DataType of the metric/tag value - optional bool is_historical = 5; // If this is historical data and should not update real time tag - optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag - optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes. - optional MetaData metadata = 8; // Metadata for the payload + optional string name = 1; // Metric name - should only be included on birth + optional uint64 alias = 2; // Metric alias - tied to name on birth and included in all later DATA messages + optional uint64 timestamp = 3; // Timestamp associated with data acquisition time + optional uint32 datatype = 4; // DataType of the metric/tag value + optional bool is_historical = 5; // If this is historical data and should not update real time tag + optional bool is_transient = 6; // Tells consuming clients such as MQTT Engine to not store this as a tag + optional bool is_null = 7; // If this is null - explicitly say so rather than using -1, false, etc for some datatypes. + optional MetaData metadata = 8; // Metadata for the payload optional PropertySet properties = 9; oneof value { - uint32 int_value = 10; - uint64 long_value = 11; - float float_value = 12; - double double_value = 13; - bool boolean_value = 14; - string string_value = 15; - bytes bytes_value = 16; // Bytes, File - DataSet dataset_value = 17; - Template template_value = 18; - MetricValueExtension extension_value = 19; + uint32 int_value = 10; + uint64 long_value = 11; + float float_value = 12; + double double_value = 13; + bool boolean_value = 14; + string string_value = 15; + bytes bytes_value = 16; // Bytes, File + DataSet dataset_value = 17; + Template template_value = 18; + MetricValueExtension extension_value = 19; } message MetricValueExtension { - google.protobuf.Any extensions = 1; + google.protobuf.Any extensions = 1; } } - optional uint64 timestamp = 1; // Timestamp at message sending time - repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs - optional uint64 seq = 3; // Sequence number - optional string uuid = 4; // UUID to track message type in terms of schema definitions - optional bytes body = 5; // To optionally bypass the whole definition above - google.protobuf.Any extensions = 6; + optional uint64 timestamp = 1; // Timestamp at message sending time + repeated Metric metrics = 2; // Repeated forever - no limit in Google Protobufs + optional uint64 seq = 3; // Sequence number + optional string uuid = 4; // UUID to track message type in terms of schema definitions + optional bytes body = 5; // To optionally bypass the whole definition above + google.protobuf.Any extensions = 6; } \ No newline at end of file diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 25190cf24a..2289b53aed 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 79e4387744..71bd0c17fe 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.1.0-RC + 4.2.0-RC transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 181b958649..29ace644a9 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.common.transport diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index 0884208aee..6c73bb03de 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -20,14 +20,17 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.boot.ssl.SslStoreBundle; import org.springframework.boot.web.server.Ssl; -import org.springframework.boot.web.server.SslStoreProvider; import org.springframework.boot.web.server.WebServerFactoryCustomizer; import org.springframework.boot.web.servlet.server.ConfigurableServletWebServerFactory; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; -import java.security.KeyStore; +import java.util.List; +import java.util.function.Consumer; @Component @ConditionalOnExpression("'${spring.main.web-environment:true}'=='true' && '${server.ssl.enabled:false}'=='true'") @@ -43,6 +46,9 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom @Qualifier("httpServerSslCredentials") private SslCredentialsConfig httpServerSslCredentialsConfig; + @Autowired + SslBundles sslBundles; + private final ServerProperties serverProperties; public SslCredentialsWebServerCustomizer(ServerProperties serverProperties) { @@ -56,16 +62,32 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom ssl.setKeyAlias(sslCredentials.getKeyAlias()); ssl.setKeyPassword(sslCredentials.getKeyPassword()); factory.setSsl(ssl); - factory.setSslStoreProvider(new SslStoreProvider() { + factory.setSslBundles(sslBundles); + } + + @Bean + public SslBundles sslBundles() { + SslStoreBundle storeBundle = SslStoreBundle.of( + httpServerSslCredentialsConfig.getCredentials().getKeyStore(), + httpServerSslCredentialsConfig.getCredentials().getKeyPassword(), + null + ); + return new SslBundles() { @Override - public KeyStore getKeyStore() { - return sslCredentials.getKeyStore(); + public SslBundle getBundle(String name) { + return SslBundle.of(storeBundle); } @Override - public KeyStore getTrustStore() { - return null; + public List getBundleNames() { + return List.of("default"); } - }); + + @Override + public void addBundleUpdateHandler(String name, Consumer handler) { + // no-op + } + }; } + } diff --git a/common/util/pom.xml b/common/util/pom.xml index 200eaed579..3f07b2c05b 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common @@ -114,7 +114,10 @@ net.objecthunter exp4j - ${exp4j.version} + + + com.networknt + json-schema-validator com.github.ben-manes.caffeine diff --git a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java index 2c214460f6..001513b008 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java @@ -26,11 +26,13 @@ import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Clock; import java.util.Base64; import java.util.Iterator; @Slf4j public final class AzureIotHubUtil { + private static final String BASE_DIR_PATH = System.getProperty("user.dir"); private static final String APP_DIR = "application"; private static final String SRC_DIR = "src"; @@ -52,41 +54,37 @@ public final class AzureIotHubUtil { } } - private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; - private static final long ONE_SECOND_IN_MILLISECONDS = 1000; + private static final long SAS_TOKEN_VALID_SECS = 365 * 24 * 60 * 60; // one year private static final String SAS_TOKEN_FORMAT = "SharedAccessSignature sr=%s&sig=%s&se=%s"; private static final String USERNAME_FORMAT = "%s/%s/?api-version=2018-06-30"; - private AzureIotHubUtil() { - } + private AzureIotHubUtil() {} public static String buildUsername(String host, String deviceId) { return String.format(USERNAME_FORMAT, host, deviceId); } - public static String buildSasToken(String host, String sasKey) { + public static String buildSasToken(String host, String sasKey, Clock clock) { try { - final String targetUri = URLEncoder.encode(host.toLowerCase(), "UTF-8"); - final long expiryTime = buildExpiresOn(); + final String targetUri = URLEncoder.encode(host.toLowerCase(), StandardCharsets.UTF_8); + final long expiryTime = buildExpiresOn(clock); String toSign = targetUri + "\n" + expiryTime; byte[] keyBytes = Base64.getDecoder().decode(sasKey.getBytes(StandardCharsets.UTF_8)); SecretKeySpec signingKey = new SecretKeySpec(keyBytes, "HmacSHA256"); Mac mac = Mac.getInstance("HmacSHA256"); mac.init(signingKey); byte[] rawHmac = mac.doFinal(toSign.getBytes(StandardCharsets.UTF_8)); - String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), "UTF-8"); + String signature = URLEncoder.encode(Base64.getEncoder().encodeToString(rawHmac), StandardCharsets.UTF_8); return String.format(SAS_TOKEN_FORMAT, targetUri, signature, expiryTime); } catch (Exception e) { - throw new RuntimeException("Failed to build SAS token!!!", e); + throw new RuntimeException("Failed to build SAS token!", e); } } - private static long buildExpiresOn() { - long expiresOnDate = System.currentTimeMillis(); - expiresOnDate += SAS_TOKEN_VALID_SECS * ONE_SECOND_IN_MILLISECONDS; - return expiresOnDate / ONE_SECOND_IN_MILLISECONDS; + private static long buildExpiresOn(Clock clock) { + return clock.instant().plusSeconds(SAS_TOKEN_VALID_SECS).getEpochSecond(); } public static String getDefaultCaCert() { diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index d153501b92..cd61c7ac20 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -62,9 +62,6 @@ import java.util.function.BiFunction; import java.util.function.UnaryOperator; import java.util.regex.Pattern; -/** - * Created by Valerii Sosliuk on 5/12/2017. - */ @Slf4j public class JacksonUtil { diff --git a/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java new file mode 100644 index 0000000000..57ddd8a5ac --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/JsonSchemaUtils.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.common.util; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SchemaId; +import com.networknt.schema.SchemaLocation; +import com.networknt.schema.SpecVersion; +import com.networknt.schema.ValidationMessage; + +import java.util.Set; + +public final class JsonSchemaUtils { + + private JsonSchemaUtils() {} + + /** + * Validates that the provided ObjectNode is a valid JSON Schema (Draft 2020-12). + * + * @param schemaNode the JSON Schema document as an ObjectNode + * @return true if the schema is well-formed, false otherwise + */ + public static boolean isValidJsonSchema(ObjectNode schemaNode) { + Set errors = JsonSchemaFactory + .getInstance(SpecVersion.VersionFlag.V202012) + .getSchema(SchemaLocation.of(SchemaId.V202012)) + .validate(schemaNode); + return errors.isEmpty(); + } + +} diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 32c7210d0e..19278b8d13 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 6680fc0d1e..a32d0c4f6d 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard dao diff --git a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java index 43b651bb24..7b158fe9a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java @@ -43,10 +43,9 @@ import java.util.function.Consumer; import java.util.function.Function; import java.util.stream.Collectors; -public abstract class DaoUtil { +public final class DaoUtil { - private DaoUtil() { - } + private DaoUtil() {} public static PageData toPageData(Page> page) { List data = convertDataList(page.getContent()); @@ -98,17 +97,17 @@ public abstract class DaoUtil { return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), pageLink.toSort(sortOrders, columnMap, addDefaultSorting)); } - public static List convertDataList(Collection> toDataList) { - List list = Collections.emptyList(); - if (toDataList != null && !toDataList.isEmpty()) { - list = new ArrayList<>(); - for (ToData object : toDataList) { - if (object != null) { - list.add(object.toData()); - } + public static List convertDataList(Collection> toConvert) { + if (CollectionUtils.isEmpty(toConvert)) { + return Collections.emptyList(); + } + List converted = new ArrayList<>(toConvert.size()); + for (ToData object : toConvert) { + if (object != null) { + converted.add(object.toData()); } } - return list; + return converted; } public static T getData(ToData data) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java new file mode 100644 index 0000000000..b0d4b6fdb6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheEvictEvent.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.ai; + +import org.thingsboard.server.common.data.ai.AiModel; + +import static java.util.Objects.requireNonNull; +import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Deleted; +import static org.thingsboard.server.dao.ai.AiModelCacheEvictEvent.Saved; + +sealed interface AiModelCacheEvictEvent permits Saved, Deleted { + + AiModelCacheKey cacheKey(); + + record Saved(AiModelCacheKey cacheKey, AiModel savedModel) implements AiModelCacheEvictEvent { + + public Saved { + requireNonNull(cacheKey); + requireNonNull(savedModel); + } + + } + + record Deleted(AiModelCacheKey cacheKey) implements AiModelCacheEvictEvent { + + public Deleted { + requireNonNull(cacheKey); + } + + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java new file mode 100644 index 0000000000..6b73ad7b28 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCacheKey.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.ai; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.thingsboard.server.cache.VersionedCacheKey; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +import java.util.UUID; + +import static java.util.Objects.requireNonNull; + +record AiModelCacheKey(UUID tenantId, UUID modelId) implements VersionedCacheKey { + + AiModelCacheKey { + requireNonNull(tenantId); + requireNonNull(modelId); + + if (TenantId.SYS_TENANT_ID.getId().equals(tenantId)) { + throw new IllegalArgumentException("Tenant ID must not be the system tenant ID"); + } + if (EntityId.NULL_UUID.equals(modelId)) { + throw new IllegalArgumentException("Model ID must not be reserved null UUID"); + } + } + + static AiModelCacheKey of(TenantId tenantId, AiModelId modelId) { + return new AiModelCacheKey(tenantId.getId(), modelId.getId()); + } + + @Override + public boolean isVersioned() { + return true; + } + + @NonNull + @Override + public String toString() { + return /* cache name */ "_" + tenantId + "_" + modelId; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java new file mode 100644 index 0000000000..165efcd4e2 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * 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.dao.ai; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.VersionedCaffeineTbCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.ai.AiModel; + +@Component("AiModelCache") +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +class AiModelCaffeineCache extends VersionedCaffeineTbCache { + + AiModelCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.AI_MODEL_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java new file mode 100644 index 0000000000..e788685bfa --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelDao.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.ai; + +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.ExportableEntityDao; +import org.thingsboard.server.dao.TenantEntityDao; + +import java.util.Optional; +import java.util.Set; + +public interface AiModelDao extends TenantEntityDao, ExportableEntityDao { + + Optional findByTenantIdAndId(TenantId tenantId, AiModelId modelId); + + boolean deleteById(TenantId tenantId, AiModelId modelId); + + Set deleteByTenantId(TenantId tenantId); + + boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java new file mode 100644 index 0000000000..7bec37875f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelRedisCache.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.ai; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Component; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.cache.VersionedRedisTbCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.ai.AiModel; + +@Component("AiModelCache") +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +class AiModelRedisCache extends VersionedRedisTbCache { + + AiModelRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.AI_MODEL_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(AiModel.class)); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java new file mode 100644 index 0000000000..b091a29247 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiModelServiceImpl.java @@ -0,0 +1,149 @@ +/** + * 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.dao.ai; + +import com.google.common.util.concurrent.FluentFuture; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.AiModelId; +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.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.entity.CachedVersionedEntityService; +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 static org.thingsboard.server.dao.service.Validator.validatePageLink; + +@Service +@RequiredArgsConstructor +class AiModelServiceImpl extends CachedVersionedEntityService implements AiModelService { + + private final DataValidator aiModelValidator; + + private final JpaExecutorService jpaExecutor; + private final AiModelDao aiModelDao; + + @Override + @TransactionalEventListener + public void handleEvictEvent(AiModelCacheEvictEvent event) { + var cacheKey = event.cacheKey(); + if (event instanceof AiModelCacheEvictEvent.Saved savedEvent) { + cache.put(cacheKey, savedEvent.savedModel()); + } else if (event instanceof AiModelCacheEvictEvent.Deleted) { + cache.evict(cacheKey); + } else { + throw new UnsupportedOperationException("Unsupported event type: " + event.getClass().getSimpleName()); + } + } + + @Override + @Transactional + public AiModel save(AiModel model) { + aiModelValidator.validate(model, AiModel::getTenantId); + + AiModel savedModel; + try { + savedModel = aiModelDao.saveAndFlush(model.getTenantId(), model); + } catch (Exception e) { + checkConstraintViolation(e, + "ai_model_name_unq_key", "AI model with such name already exist!", + "ai_model_external_id_unq_key", "AI model with such external ID already exists!"); + throw e; + } + + var cacheKey = AiModelCacheKey.of(savedModel.getTenantId(), savedModel.getId()); + publishEvictEvent(new AiModelCacheEvictEvent.Saved(cacheKey, savedModel)); + + return savedModel; + } + + @Override + public Optional findAiModelById(TenantId tenantId, AiModelId modelId) { + return Optional.ofNullable(aiModelDao.findById(tenantId, modelId.getId())); + } + + @Override + public PageData findAiModelsByTenantId(TenantId tenantId, PageLink pageLink) { + validatePageLink(pageLink, AiModelEntity.ALLOWED_SORT_PROPERTIES); + return aiModelDao.findAllByTenantId(tenantId, pageLink); + } + + @Override + public Optional findAiModelByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + var cacheKey = AiModelCacheKey.of(tenantId, modelId); + return Optional.ofNullable(cache.get(cacheKey, () -> aiModelDao.findByTenantIdAndId(tenantId, modelId).orElse(null))); + } + + @Override + public FluentFuture> findAiModelByTenantIdAndIdAsync(TenantId tenantId, AiModelId modelId) { + return FluentFuture.from(jpaExecutor.submit(() -> findAiModelByTenantIdAndId(tenantId, modelId))); + } + + @Override + @Transactional + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return deleteByTenantIdAndIdInternal(tenantId, modelId); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return findAiModelByTenantIdAndId(tenantId, (AiModelId) entityId) + .map(model -> model); // necessary to cast to HasId + } + + @Override + public long countByTenantId(TenantId tenantId) { + return aiModelDao.countByTenantId(tenantId); + } + + @Override + @Transactional + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + deleteByTenantIdAndIdInternal(tenantId, new AiModelId(id.getId())); + } + + private boolean deleteByTenantIdAndIdInternal(TenantId tenantId, AiModelId modelId) { + boolean deleted = aiModelDao.deleteByTenantIdAndId(tenantId, modelId); + if (deleted) { + publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, modelId))); + } + return deleted; + } + + @Override + @Transactional + public void deleteByTenantId(TenantId tenantId) { + Set deleted = aiModelDao.deleteByTenantId(tenantId); + deleted.forEach(id -> publishEvictEvent(new AiModelCacheEvictEvent.Deleted(AiModelCacheKey.of(tenantId, id)))); + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java index 0351490f70..d001258ba4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/AlarmDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.alarm; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.alarm.Alarm; @@ -47,15 +48,14 @@ import java.util.List; import java.util.Set; import java.util.UUID; -/** - * Created by ashvayka on 11.05.17. - */ public interface AlarmDao extends Dao { Alarm findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type); Alarm findLatestActiveByOriginatorAndType(TenantId tenantId, EntityId originator, String type); + FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); + ListenableFuture findLatestByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); Alarm findAlarmById(TenantId tenantId, UUID key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java index 23e05e1bfa..5c3e4f3c79 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmService.java @@ -17,7 +17,7 @@ package org.thingsboard.server.dao.alarm; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.base.Function; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -67,7 +67,6 @@ import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.ConstraintValidator; -import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.tenant.TenantService; import java.util.ArrayList; @@ -97,7 +96,6 @@ public class BaseAlarmService extends AbstractCachedEntityService alarmDataValidator; @TransactionalEventListener(classes = AlarmTypesCacheEvictEvent.class) @Override @@ -169,6 +167,11 @@ public class BaseAlarmService extends AbstractCachedEntityService findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { + return alarmDao.findLatestActiveByOriginatorAndTypeAsync(tenantId, originator, type); + } + @Override public PageData findAlarmDataByQueryForEntities(TenantId tenantId, AlarmDataQuery query, Collection orderedEntityIds) { @@ -438,12 +441,6 @@ public class BaseAlarmService extends AbstractCachedEntityService T getAndUpdate(TenantId tenantId, AlarmId alarmId, Function function) { - validateId(alarmId, "Alarm id should be specified!"); - Alarm entity = alarmDao.findAlarmById(tenantId, alarmId.getId()); - return function.apply(entity); - } - @Override public Optional> findEntity(TenantId tenantId, EntityId entityId) { return Optional.ofNullable(findAlarmById(tenantId, new AlarmId(entityId.getId()))); diff --git a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java index 0340cd0fde..2f23d20f00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/housekeeper/CleanUpService.java @@ -47,7 +47,7 @@ public class CleanUpService { private final Set skippedEntities = EnumSet.of( EntityType.ALARM, EntityType.QUEUE, EntityType.TB_RESOURCE, EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, EntityType.AI_MODEL ); @TransactionalEventListener(fallbackExecution = true) // after transaction commit diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java index 084df4455c..dda45539f9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/BaseSqlEntity.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.sql.IdGenerator.GeneratedId; import java.util.Arrays; import java.util.Collections; @@ -44,6 +45,7 @@ public abstract class BaseSqlEntity implements BaseEntity { @Id @Column(name = ModelConstants.ID_PROPERTY, columnDefinition = "uuid") + @GeneratedId protected UUID id; @Column(name = ModelConstants.CREATED_TIME_PROPERTY, updatable = false) diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 23324eccb5..3960c67525 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -457,6 +457,7 @@ public class ModelConstants { */ public static final String MOBILE_APP_TABLE_NAME = "mobile_app"; public static final String MOBILE_APP_PKG_NAME_PROPERTY = "pkg_name"; + public static final String MOBILE_APP_TITLE_PROPERTY = "title"; public static final String MOBILE_APP_APP_SECRET_PROPERTY = "app_secret"; public static final String MOBILE_APP_PLATFORM_TYPE_PROPERTY = "platform_type"; public static final String MOBILE_APP_STATUS_PROPERTY = "status"; @@ -751,6 +752,14 @@ public class ModelConstants { public static final String JOB_CONFIGURATION_PROPERTY = "configuration"; public static final String JOB_RESULT_PROPERTY = "result"; + /** + * AI model constants. + */ + public static final String AI_MODEL_TABLE_NAME = "ai_model"; + public static final String AI_MODEL_TENANT_ID_COLUMN_NAME = TENANT_ID_COLUMN; + public static final String AI_MODEL_NAME_COLUMN_NAME = NAME_PROPERTY; + public static final String AI_MODEL_CONFIGURATION_COLUMN_NAME = "configuration"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java new file mode 100644 index 0000000000..d4f3d36db6 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AiModelEntity.java @@ -0,0 +1,110 @@ +/** + * 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.dao.model.sql; + +import io.hypersistence.utils.hibernate.type.json.JsonBinaryType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.Type; +import org.hibernate.proxy.HibernateProxy; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.model.BaseVersionedEntity; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +@Getter +@Setter +@ToString +@Entity +@Table(name = ModelConstants.AI_MODEL_TABLE_NAME) +public class AiModelEntity extends BaseVersionedEntity { + + public static final Map COLUMN_MAP = Map.of( + "createdTime", "created_time", + "provider", "(configuration ->> 'provider')", + "modelId", "(configuration ->> 'modelId')" + ); + + public static final Set ALLOWED_SORT_PROPERTIES = Collections.unmodifiableSet( + new LinkedHashSet<>(List.of("createdTime", "name", "provider", "modelId")) + ); + + @Column(name = ModelConstants.AI_MODEL_TENANT_ID_COLUMN_NAME, nullable = false, columnDefinition = "UUID") + private UUID tenantId; + + @Column(name = ModelConstants.AI_MODEL_NAME_COLUMN_NAME, nullable = false) + private String name; + + @Type(JsonBinaryType.class) + @Column(name = ModelConstants.AI_MODEL_CONFIGURATION_COLUMN_NAME, nullable = false, columnDefinition = "JSONB") + private AiModelConfig configuration; + + @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY, columnDefinition = "UUID") + private UUID externalId; + + public AiModelEntity() {} + + public AiModelEntity(AiModel aiModel) { + super(aiModel); + tenantId = getTenantUuid(aiModel.getTenantId()); + name = aiModel.getName(); + configuration = aiModel.getConfiguration(); + externalId = getUuid(aiModel.getExternalId()); + } + + @Override + public AiModel toData() { + var model = new AiModel(new AiModelId(id)); + model.setCreatedTime(createdTime); + model.setVersion(version); + model.setTenantId(TenantId.fromUUID(tenantId)); + model.setName(name); + model.setConfiguration(configuration); + model.setExternalId(getEntityId(externalId, AiModelId::new)); + return model; + } + + @Override + public final boolean equals(Object o) { + if (this == o) return true; + if (o == null) return false; + Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + if (thisEffectiveClass != oEffectiveClass) return false; + AiModelEntity that = (AiModelEntity) o; + return getId() != null && Objects.equals(getId(), that.getId()); + } + + @Override + public final int hashCode() { + return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java index 70f711750d..d9c22c6e9b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppEntity.java @@ -53,6 +53,9 @@ public class MobileAppEntity extends BaseSqlEntity { @Column(name = ModelConstants.MOBILE_APP_PKG_NAME_PROPERTY) private String pkgName; + @Column(name = ModelConstants.MOBILE_APP_TITLE_PROPERTY) + private String title; + @Column(name = ModelConstants.MOBILE_APP_APP_SECRET_PROPERTY) private String appSecret; @@ -82,6 +85,7 @@ public class MobileAppEntity extends BaseSqlEntity { this.tenantId = mobile.getTenantId().getId(); } this.pkgName = mobile.getPkgName(); + this.title = mobile.getTitle(); this.appSecret = mobile.getAppSecret(); this.platformType = mobile.getPlatformType(); this.status = mobile.getStatus(); @@ -98,6 +102,7 @@ public class MobileAppEntity extends BaseSqlEntity { } mobile.setCreatedTime(createdTime); mobile.setPkgName(pkgName); + mobile.setTitle(title); mobile.setAppSecret(appSecret); mobile.setPlatformType(platformType); mobile.setStatus(status); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java index 1ffafd305e..6ee4bdd89f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageEntity.java @@ -38,6 +38,7 @@ import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.nio.ByteBuffer; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.EXTERNAL_ID_PROPERTY; import static org.thingsboard.server.dao.model.ModelConstants.OTA_PACKAGE_CHECKSUM_ALGORITHM_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.OTA_PACKAGE_CHECKSUM_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.OTA_PACKAGE_CONTENT_TYPE_COLUMN; @@ -105,6 +106,9 @@ public class OtaPackageEntity extends BaseSqlEntity { @Column(name = ModelConstants.OTA_PACKAGE_ADDITIONAL_INFO_COLUMN) private JsonNode additionalInfo; + @Column(name = EXTERNAL_ID_PROPERTY) + private UUID externalId; + public OtaPackageEntity() { super(); } @@ -128,6 +132,7 @@ public class OtaPackageEntity extends BaseSqlEntity { this.data = otaPackage.getData().array(); this.dataSize = otaPackage.getDataSize(); this.additionalInfo = otaPackage.getAdditionalInfo(); + this.externalId = getUuid(otaPackage.getExternalId()); } @Override @@ -153,6 +158,8 @@ public class OtaPackageEntity extends BaseSqlEntity { otaPackage.setHasData(true); } otaPackage.setAdditionalInfo(additionalInfo); + otaPackage.setExternalId(getEntityId(externalId, OtaPackageId::new)); return otaPackage; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java index c1e83cf511..a1625f2595 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/OtaPackageInfoEntity.java @@ -100,6 +100,9 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { @Column(name = ModelConstants.OTA_PACKAGE_ADDITIONAL_INFO_COLUMN) private JsonNode additionalInfo; + @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) + private UUID externalId; + @Transient private boolean hasData; @@ -125,11 +128,12 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { this.checksum = otaPackageInfo.getChecksum(); this.dataSize = otaPackageInfo.getDataSize(); this.additionalInfo = otaPackageInfo.getAdditionalInfo(); + this.externalId = getUuid(otaPackageInfo.getExternalId()); } public OtaPackageInfoEntity(UUID id, long createdTime, UUID tenantId, UUID deviceProfileId, OtaPackageType type, String title, String version, String tag, String url, String fileName, String contentType, ChecksumAlgorithm checksumAlgorithm, String checksum, Long dataSize, - Object additionalInfo, boolean hasData) { + Object additionalInfo, UUID externalId, boolean hasData) { this.id = id; this.createdTime = createdTime; this.tenantId = tenantId; @@ -146,6 +150,7 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { this.dataSize = dataSize; this.hasData = hasData; this.additionalInfo = JacksonUtil.convertValue(additionalInfo, JsonNode.class); + this.externalId = externalId; } @Override @@ -168,6 +173,8 @@ public class OtaPackageInfoEntity extends BaseSqlEntity { otaPackageInfo.setDataSize(dataSize); otaPackageInfo.setAdditionalInfo(additionalInfo); otaPackageInfo.setHasData(hasData); + otaPackageInfo.setExternalId(getEntityId(externalId, OtaPackageId::new)); return otaPackageInfo; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java index bc84a58cd9..70112fa72f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/oauth2/HybridClientRegistrationRepository.java @@ -29,6 +29,7 @@ import java.util.UUID; @Component public class HybridClientRegistrationRepository implements ClientRegistrationRepository { + private static final String defaultRedirectUriTemplate = "{baseUrl}/login/oauth2/code/{registrationId}"; @Autowired @@ -37,11 +38,13 @@ public class HybridClientRegistrationRepository implements ClientRegistrationRep @Override public ClientRegistration findByRegistrationId(String registrationId) { OAuth2Client oAuth2Client = oAuth2ClientService.findOAuth2ClientById(TenantId.SYS_TENANT_ID, new OAuth2ClientId(UUID.fromString(registrationId))); - return oAuth2Client == null ? - null : toSpringClientRegistration(oAuth2Client); + if (oAuth2Client == null) { + return null; + } + return toSpringClientRegistration(oAuth2Client); } - private ClientRegistration toSpringClientRegistration(OAuth2Client oAuth2Client){ + private ClientRegistration toSpringClientRegistration(OAuth2Client oAuth2Client) { String registrationId = oAuth2Client.getUuidId().toString(); // NONE is used if we need pkce-based code challenge @@ -67,4 +70,5 @@ public class HybridClientRegistrationRepository implements ClientRegistrationRep .redirectUri(defaultRedirectUriTemplate) .build(); } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java index 16d8517b6d..343a2485ce 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java @@ -54,6 +54,7 @@ import static org.thingsboard.server.dao.service.Validator.validatePageLink; @Slf4j @RequiredArgsConstructor public class BaseOtaPackageService extends AbstractCachedEntityService implements OtaPackageService { + public static final String INCORRECT_OTA_PACKAGE_ID = "Incorrect otaPackageId "; public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; @@ -73,7 +74,7 @@ public class BaseOtaPackageService extends AbstractCachedEntityService Hashing.md5(); + case SHA256 -> Hashing.sha256(); + case SHA384 -> Hashing.sha384(); + case SHA512 -> Hashing.sha512(); + case CRC32 -> Hashing.crc32(); + case MURMUR3_32 -> Hashing.murmur3_32(); + case MURMUR3_128 -> Hashing.murmur3_128(); + default -> throw new DataValidationException("Unknown checksum algorithm!"); + }; } @Override @@ -171,6 +160,12 @@ public class BaseOtaPackageService extends AbstractCachedEntityService otaPackageInfoDao.findById(tenantId, otaPackageId.getId()), true); } + @Override + public OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version) { + log.trace("Executing findOtaPackageByTenantIdAndTitle [{}] [{}] [{}]", tenantId, title, version); + return otaPackageDao.findOtaPackageByTenantIdAndTitleAndVersion(tenantId, title, version); + } + @Override public ListenableFuture findOtaPackageInfoByIdAsync(TenantId tenantId, OtaPackageId otaPackageId) { log.trace("Executing findOtaPackageInfoByIdAsync [{}]", otaPackageId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java index f8f877e55e..cc1ff58ded 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java @@ -16,12 +16,17 @@ package org.thingsboard.server.dao.ota; import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.dao.Dao; +import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.TenantEntityWithDataDao; -public interface OtaPackageDao extends Dao, TenantEntityWithDataDao { +public interface OtaPackageDao extends Dao, TenantEntityWithDataDao, ExportableEntityDao { Long sumDataSizeByTenantId(TenantId tenantId); + OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index f836f229ea..0fd55bb494 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -21,7 +21,6 @@ import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.metadata.ConstraintDescriptor; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.hibernate.validator.HibernateValidator; import org.hibernate.validator.HibernateValidatorConfiguration; @@ -32,15 +31,16 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoNullChar; import org.thingsboard.server.common.data.validation.NoXss; import org.thingsboard.server.common.data.validation.RateLimit; +import org.thingsboard.server.common.data.validation.ValidJsonSchema; import org.thingsboard.server.dao.exception.DataValidationException; import java.util.Collection; import java.util.Set; import java.util.stream.Collectors; -@Slf4j @Configuration public class ConstraintValidator { @@ -88,7 +88,9 @@ public class ConstraintValidator { ConstraintMapping constraintMapping = getCustomConstraintMapping(); validatorConfiguration.addMapping(constraintMapping); - fieldsValidator = validatorConfiguration.buildValidatorFactory().getValidator(); + try (var validatorFactory = validatorConfiguration.buildValidatorFactory()) { + fieldsValidator = validatorFactory.getValidator(); + } } @Bean @@ -105,6 +107,8 @@ public class ConstraintValidator { constraintMapping.constraintDefinition(NoXss.class).validatedBy(NoXssValidator.class); constraintMapping.constraintDefinition(Length.class).validatedBy(StringLengthValidator.class); constraintMapping.constraintDefinition(RateLimit.class).validatedBy(RateLimitValidator.class); + constraintMapping.constraintDefinition(NoNullChar.class).validatedBy(NoNullCharValidator.class); + constraintMapping.constraintDefinition(ValidJsonSchema.class).validatedBy(JsonSchemaValidator.class); return constraintMapping; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java new file mode 100644 index 0000000000..eefefbb3d7 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/JsonSchemaValidator.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.thingsboard.common.util.JsonSchemaUtils; +import org.thingsboard.server.common.data.validation.ValidJsonSchema; + +public final class JsonSchemaValidator implements ConstraintValidator { + + @Override + public boolean isValid(ObjectNode schema, ConstraintValidatorContext context) { + return schema == null || JsonSchemaUtils.isValidJsonSchema(schema); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java new file mode 100644 index 0000000000..afbcabe91b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/NoNullCharValidator.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.service; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.thingsboard.server.common.data.validation.NoNullChar; + +public final class NoNullCharValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value == null || !value.contains("\u0000"); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java b/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java index e051e99dc5..f94026ce89 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/Validator.java @@ -26,11 +26,14 @@ import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.dao.exception.IncorrectParameterException; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.regex.Pattern; -public class Validator { +public final class Validator { + + private Validator() {} public static final Pattern PROPERTY_PATTERN = Pattern.compile("^[\\p{L}0-9_-]+$"); // Unicode letters, numbers, '_' and '-' allowed @@ -204,22 +207,61 @@ public class Validator { } /** - * This method validate PageLink page link. If pageLink is invalid than throw - * IncorrectParameterException exception + * Validates the specified PageLink object delegating to {@link #validatePageLink(PageLink, Set)} + * with no restrictions on allowed sort properties. * - * @param pageLink the page link + * @param pageLink the PageLink object to validate + * @throws IncorrectParameterException if the pageLink is null, has invalid page size, + * invalid page number, or invalid sort property + * @see #validatePageLink(PageLink, Set) */ public static void validatePageLink(PageLink pageLink) { + validatePageLink(pageLink, null); + } + + /** + * Validates the specified PageLink object ensuring that: + *
    + *
  • The PageLink object is not null
  • + *
  • The page size is greater than zero
  • + *
  • The page number is non-negative
  • + *
  • If sorting is specified, the sort property is valid and allowed
  • + *
+ * + *

When {@code allowedSortProperties} is provided, the sort property + * must be contained within this set. If {@code allowedSortProperties} is null, + * only basic sort property validation is performed. + * + * @param pageLink the PageLink object to validate. + * @param allowedSortProperties a Set of allowed sort property names, or null to skip + * this validation. If provided and the PageLink contains + * a sort order, the sort property must be in this set. + * @throws IncorrectParameterException if any of the following conditions are met: + *

    + *
  • {@code pageLink} is null
  • + *
  • page size is less than 1
  • + *
  • page number is negative
  • + *
  • sort property is malformed
  • + *
  • sort property is not in the {@code allowedSortProperties} set (when the set is provided and not null)
  • + *
+ */ + public static void validatePageLink(PageLink pageLink, Set allowedSortProperties) { if (pageLink == null) { throw new IncorrectParameterException("Page link must be specified."); } else if (pageLink.getPageSize() < 1) { - throw new IncorrectParameterException("Incorrect page link page size '"+pageLink.getPageSize()+"'. Page size must be greater than zero."); + throw new IncorrectParameterException("Incorrect page link page size '" + pageLink.getPageSize() + "'. Page size must be greater than zero."); } else if (pageLink.getPage() < 0) { - throw new IncorrectParameterException("Incorrect page link page '"+pageLink.getPage()+"'. Page must be positive integer."); + throw new IncorrectParameterException("Incorrect page link page '" + pageLink.getPage() + "'. Page must be positive integer."); } else if (pageLink.getSortOrder() != null) { - if (!isValidProperty(pageLink.getSortOrder().getProperty())) { + String sortProperty = pageLink.getSortOrder().getProperty(); + if (!isValidProperty(sortProperty)) { throw new IncorrectParameterException("Invalid page link sort property"); } + if (allowedSortProperties != null && !allowedSortProperties.contains(sortProperty)) { + throw new IncorrectParameterException( + "Unsupported sort property '" + sortProperty + "'. Only '" + String.join("', '", allowedSortProperties) + "' are allowed." + ); + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java new file mode 100644 index 0000000000..fdccf2955f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java @@ -0,0 +1,69 @@ +/** + * 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.dao.service.validator; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.dao.ai.AiModelDao; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.tenant.TenantService; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +class AiModelDataValidator extends DataValidator { + + private final TenantService tenantService; + private final AiModelDao aiModelDao; + + @Override + protected AiModel validateUpdate(TenantId tenantId, AiModel model) { + Optional existing = aiModelDao.findByTenantIdAndId(tenantId, model.getId()); + if (existing.isEmpty()) { + throw new DataValidationException("Cannot update non-existent AI model!"); + } + return existing.get(); + } + + @Override + protected void validateDataImpl(TenantId tenantId, AiModel model) { + // ID validation + if (model.getId() != null) { + if (model.getUuidId() == null) { + throw new DataValidationException("AI model UUID should be specified!"); + } + if (model.getId().isNullUid()) { + throw new DataValidationException("AI model UUID must not be the reserved null value!"); + } + } + + // tenant ID validation + if (model.getTenantId() == null || model.getTenantId().getId() == null) { + throw new DataValidationException("AI model should be assigned to tenant!"); + } + if (model.getTenantId().isSysTenantId()) { + throw new DataValidationException("AI model cannot be assigned to the system tenant!"); + } + if (!tenantService.tenantExists(tenantId)) { + throw new DataValidationException("AI model reference a non-existent tenant!"); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java index 47911a8966..c4d3f7255d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/OtaPackageDataValidator.java @@ -103,4 +103,5 @@ public class OtaPackageDataValidator extends BaseOtaPackageDataValidator { - /** - * Save or update admin settings object - * - * @param adminSettings the admin settings object - * @return saved admin settings object - */ AdminSettings save(TenantId tenantId, AdminSettings adminSettings); - - /** - * Find admin settings by key. - * - * @param key the key - * @return the admin settings object - */ + AdminSettings findByTenantIdAndKey(UUID tenantId, String key); boolean removeByTenantIdAndKey(UUID tenantId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index b1b3b25a19..604a37f4bb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java @@ -21,11 +21,16 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AdminSettingsId; +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.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; +import java.util.Optional; + @Service @Slf4j public class AdminSettingsServiceImpl implements AdminSettingsService { @@ -87,10 +92,25 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { } @Override - public void deleteAdminSettingsByTenantId(TenantId tenantId) { + public void deleteByTenantId(TenantId tenantId) { adminSettingsDao.removeByTenantId(tenantId.getId()); } + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + adminSettingsDao.removeById(tenantId, id.getId()); + } + + @Override + public Optional> findEntity(TenantId tenantId, EntityId entityId) { + return Optional.ofNullable(adminSettingsDao.findById(tenantId, entityId.getId())); + } + + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + private void dropTokenIfProviderInfoChanged(JsonNode newJsonValue, JsonNode oldJsonValue) { if (newJsonValue.has("enableOauth2") && newJsonValue.get("enableOauth2").asBoolean()) { if (!newJsonValue.get("providerId").equals(oldJsonValue.get("providerId")) || diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/IdGenerator.java b/dao/src/main/java/org/thingsboard/server/dao/sql/IdGenerator.java new file mode 100644 index 0000000000..dc8a0da32f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/IdGenerator.java @@ -0,0 +1,60 @@ +/** + * 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.dao.sql; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.annotations.IdGeneratorType; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.generator.BeforeExecutionGenerator; +import org.hibernate.generator.EventType; +import org.hibernate.generator.EventTypeSets; +import org.thingsboard.server.dao.model.BaseEntity; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.EnumSet; + +@Slf4j +public class IdGenerator implements BeforeExecutionGenerator { + + @Override + public Object generate(SharedSessionContractImplementor session, Object owner, Object currentValue, EventType eventType) { + if (owner instanceof BaseEntity entity && entity.getUuid() != null) { + return entity.getUuid(); + } + return Uuids.timeBased(); + } + + @Override + public boolean allowAssignedIdentifiers() { + return true; + } + + @Override + public EnumSet getEventTypes() { + return EventTypeSets.INSERT_ONLY; + } + + @Retention(RetentionPolicy.RUNTIME) + @Target(ElementType.FIELD) + @IdGeneratorType(IdGenerator.class) + public @interface GeneratedId { + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java index 05577b68e1..fd37b3c9dd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java @@ -22,6 +22,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.OptimisticLockException; import jakarta.persistence.PersistenceContext; import lombok.extern.slf4j.Slf4j; +import org.hibernate.Session; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.jdbc.core.JdbcTemplate; @@ -40,9 +41,6 @@ import java.util.List; import java.util.Optional; import java.util.UUID; -/** - * @author Valerii Sosliuk - */ @Slf4j @SqlDao public abstract class JpaAbstractDao, D> @@ -72,9 +70,15 @@ public abstract class JpaAbstractDao, D> log.debug("Saving entity {}", entity); boolean isNew = entity.getUuid() == null; if (isNew) { - UUID uuid = Uuids.timeBased(); - entity.setUuid(uuid); - entity.setCreatedTime(Uuids.unixTimestamp(uuid)); + entity.setCreatedTime(System.currentTimeMillis()); + } else { + if (entity.getCreatedTime() == 0) { + if (entity.getUuid().version() == 1) { + entity.setCreatedTime(Uuids.unixTimestamp(entity.getUuid())); + } else { + entity.setCreatedTime(System.currentTimeMillis()); + } + } } try { entity = doSave(entity, isNew, flush); @@ -88,35 +92,16 @@ public abstract class JpaAbstractDao, D> boolean flushed = false; EntityManager entityManager = getEntityManager(); if (isNew) { - entityManager.persist(entity); - if (entity instanceof HasVersion versionedEntity) { - versionedEntity.setVersion(1L); - } + entity = create(entity); } else { - if (entity instanceof HasVersion versionedEntity) { - if (versionedEntity.getVersion() == null) { - HasVersion existingEntity = entityManager.find(versionedEntity.getClass(), entity.getUuid()); - if (existingEntity != null) { - /* - * manually resetting the version to latest to allow force overwrite of the entity - * */ - versionedEntity.setVersion(existingEntity.getVersion()); - } else { - return doSave(entity, true, flush); - } - } - versionedEntity = entityManager.merge(versionedEntity); - entity = (E) versionedEntity; - /* - * by default, Hibernate doesn't issue an update query and thus version increment - * if the entity was not modified. to bypass this and always increment the version, we do it manually - * */ - versionedEntity.setVersion(versionedEntity.getVersion() + 1); - } else { - entity = entityManager.merge(entity); - } + entity = update(entity); } if (entity instanceof HasVersion versionedEntity) { + /* + * by default, Hibernate doesn't issue an update query and thus version increment + * if the entity was not modified. to bypass this and always increment the version, we do it manually + * */ + versionedEntity.setVersion(versionedEntity.getVersion() + 1); /* * flushing and then removing the entity from the persistence context so that it is not affected * by next flushes (e.g. when a transaction is committed) to avoid double version increment @@ -131,6 +116,48 @@ public abstract class JpaAbstractDao, D> return entity; } + private E create(E entity) { + if (entity instanceof HasVersion versionedEntity) { + versionedEntity.setVersion(0L); + } + if (entity.getUuid() == null) { + getEntityManager().persist(entity); + } else { + if (entity instanceof HasVersion) { + /* + * Hibernate 6 does not allow creating versioned entities with preset IDs. + * Bypassing by calling the underlying session directly + * */ + Session session = getEntityManager().unwrap(Session.class); + session.save(entity); + } else { + entity = getEntityManager().merge(entity); + } + } + return entity; + } + + private E update(E entity) { + if (entity instanceof HasVersion versionedEntity) { + if (versionedEntity.getVersion() == null) { + HasVersion existingEntity = entityManager.find(versionedEntity.getClass(), entity.getUuid()); + if (existingEntity != null) { + /* + * manually resetting the version to latest to allow force overwriting of the entity + * */ + versionedEntity.setVersion(existingEntity.getVersion()); + } else { + return create(entity); + } + } + versionedEntity = entityManager.merge(versionedEntity); + entity = (E) versionedEntity; + } else { + entity = entityManager.merge(entity); + } + return entity; + } + @Override @Transactional public D saveAndFlush(TenantId tenantId, D domain) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java new file mode 100644 index 0000000000..cbe681fb58 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java @@ -0,0 +1,89 @@ +/** + * 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.dao.sql.ai; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.ExportableEntityRepository; +import org.thingsboard.server.dao.model.sql.AiModelEntity; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +interface AiModelRepository extends JpaRepository, ExportableEntityRepository { + + Optional findByTenantIdAndId(UUID tenantId, UUID id); + + Optional findByTenantIdAndName(UUID tenantId, String name); + + @Query( + value = """ + SELECT * + FROM ai_model model + WHERE model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR model.name ILIKE '%' || :textSearch || '%' + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' + OR model.configuration ->> 'modelId' ILIKE '%' || :textSearch || '%') + """, + countQuery = """ + SELECT COUNT(*) + FROM ai_model model + WHERE model.tenant_id = :tenantId + AND (:textSearch IS NULL + OR model.name ILIKE '%' || :textSearch || '%' + OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%' + OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%') + """, + nativeQuery = true + ) + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); + + @Query("SELECT ai_model.id FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId") + Page findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + + @Query("SELECT externalId FROM AiModelEntity WHERE id = :id") + Optional getExternalIdById(@Param("id") UUID id); + + long countByTenantId(UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.id IN (:ids)") + int deleteByIdIn(@Param("ids") Set ids); + + @Transactional + @Modifying + @Query(value = """ + DELETE FROM ai_model + WHERE tenant_id = :tenantId + RETURNING id + """, nativeQuery = true + ) + Set deleteByTenantId(@Param("tenantId") UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM AiModelEntity ai_model WHERE ai_model.tenantId = :tenantId AND ai_model.id IN (:ids)") + int deleteByTenantIdAndIdIn(@Param("tenantId") UUID tenantId, @Param("ids") Set ids); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java new file mode 100644 index 0000000000..882c86555b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java @@ -0,0 +1,139 @@ +/** + * 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.dao.sql.ai; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.JpaSort; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntityType; +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.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.ai.AiModelDao; +import org.thingsboard.server.dao.model.sql.AiModelEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static java.util.stream.Collectors.toSet; + +@SqlDao +@Component +@RequiredArgsConstructor +class JpaAiModelDao extends JpaAbstractDao implements AiModelDao { + + private final AiModelRepository aiModelRepository; + + @Override + public Optional findByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.findByTenantIdAndId(tenantId.getId(), modelId.getId()).map(DaoUtil::getData); + } + + @Override + public AiModel findByTenantIdAndName(UUID tenantId, String name) { + return DaoUtil.getData(aiModelRepository.findByTenantIdAndName(tenantId, name)); + } + + @Override + public AiModel findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { + return DaoUtil.getData(aiModelRepository.findByTenantIdAndExternalId(tenantId, externalId)); + } + + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return findByTenantId(tenantId.getId(), pageLink); + } + + @Override + public PageData findByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.toPageData(aiModelRepository.findByTenantId( + tenantId, StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), toPageRequest(pageLink)) + ); + } + + @Override + public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.pageToPageData(aiModelRepository.findIdsByTenantId(tenantId, toPageRequest(pageLink)).map(AiModelId::new)); + } + + private static PageRequest toPageRequest(PageLink pageLink) { + Sort sort; + SortOrder sortOrder = pageLink.getSortOrder(); + if (sortOrder == null) { + sort = Sort.by(Sort.Direction.ASC, "id"); + } else { + sort = JpaSort.unsafe( + Sort.Direction.fromString(sortOrder.getDirection().name()), + AiModelEntity.COLUMN_MAP.getOrDefault(sortOrder.getProperty(), sortOrder.getProperty()) + ).and(Sort.by(Sort.Direction.ASC, "id")); + } + return PageRequest.of(pageLink.getPage(), pageLink.getPageSize(), sort); + } + + @Override + public AiModelId getExternalIdByInternal(AiModelId internalId) { + return aiModelRepository.getExternalIdById(internalId.getId()).map(AiModelId::new).orElse(null); + } + + @Override + public Long countByTenantId(TenantId tenantId) { + return aiModelRepository.countByTenantId(tenantId.getId()); + } + + @Override + public boolean deleteById(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.deleteByIdIn(Set.of(modelId.getId())) > 0; + } + + @Override + public Set deleteByTenantId(TenantId tenantId) { + return aiModelRepository.deleteByTenantId(tenantId.getId()).stream() + .map(AiModelId::new) + .collect(toSet()); + } + + @Override + public boolean deleteByTenantIdAndId(TenantId tenantId, AiModelId modelId) { + return aiModelRepository.deleteByTenantIdAndIdIn(tenantId.getId(), Set.of(modelId.getId())) > 0; + } + + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + + @Override + protected Class getEntityClass() { + return AiModelEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return aiModelRepository; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java index b9c1abe416..376f610d66 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java @@ -32,9 +32,6 @@ import java.util.List; import java.util.Set; import java.util.UUID; -/** - * Created by Valerii Sosliuk on 5/21/2017. - */ public interface AlarmRepository extends JpaRepository { @Query("SELECT a FROM AlarmEntity a WHERE a.originatorId = :originatorId AND a.type = :alarmType ORDER BY a.startTs DESC") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java index c21d8ae928..647a46aeda 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/JpaAlarmDao.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.sql.alarm; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -79,9 +80,6 @@ import static org.thingsboard.server.common.data.page.SortOrder.Direction.ASC; import static org.thingsboard.server.dao.DaoUtil.convertTenantEntityTypesToDto; import static org.thingsboard.server.dao.DaoUtil.toPageable; -/** - * Created by Valerii Sosliuk on 5/19/2017. - */ @Slf4j @Component @SqlDao @@ -124,6 +122,11 @@ public class JpaAlarmDao extends JpaAbstractDao implements A return latest.isEmpty() ? null : DaoUtil.getData(latest.get(0)); } + @Override + public FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { + return FluentFuture.from(service.submit(() -> findLatestActiveByOriginatorAndType(tenantId, originator, type))); + } + @Override public ListenableFuture findLatestByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type) { return service.submit(() -> findLatestByOriginatorAndType(tenantId, originator, type)); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java index 4e99fb57e4..4b55884792 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/JpaAssetDao.java @@ -48,9 +48,6 @@ import java.util.UUID; import static org.thingsboard.server.dao.DaoUtil.convertTenantEntityInfosToDto; -/** - * Created by Valerii Sosliuk on 5/19/2017. - */ @Component @SqlDao @Slf4j diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java index 780f67932e..7322ea4cb6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/JpaOtaPackageDao.java @@ -22,6 +22,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; +import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -43,24 +44,52 @@ public class JpaOtaPackageDao extends JpaAbstractDao getEntityClass() { - return OtaPackageEntity.class; + public Long sumDataSizeByTenantId(TenantId tenantId) { + return otaPackageRepository.sumDataSizeByTenantId(tenantId.getId()); } + @Transactional @Override - protected JpaRepository getRepository() { - return otaPackageRepository; + public OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version) { + return DaoUtil.getData(otaPackageRepository.findByTenantIdAndTitleAndVersion(tenantId.getId(), title, version)); } + @Transactional @Override - public Long sumDataSizeByTenantId(TenantId tenantId) { - return otaPackageRepository.sumDataSizeByTenantId(tenantId.getId()); + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(otaPackageRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); } @Transactional @Override - public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { - return DaoUtil.toPageData(otaPackageRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + public PageData findByTenantId(UUID tenantId, PageLink pageLink) { + return findAllByTenantId(TenantId.fromUUID(tenantId), pageLink); + } + + @Override + public PageData findIdsByTenantId(UUID tenantId, PageLink pageLink) { + return DaoUtil.pageToPageData(otaPackageRepository.findIdsByTenantId(tenantId, DaoUtil.toPageable(pageLink)).map(OtaPackageId::new)); + } + + @Transactional + @Override + public OtaPackage findByTenantIdAndExternalId(UUID tenantId, UUID externalId) { + return DaoUtil.getData(otaPackageRepository.findByTenantIdAndExternalId(tenantId, externalId)); + } + + @Override + public OtaPackageId getExternalIdByInternal(OtaPackageId internalId) { + return DaoUtil.toEntityId(otaPackageRepository.getExternalIdById(internalId.getId()), OtaPackageId::new); + } + + @Override + protected Class getEntityClass() { + return OtaPackageEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return otaPackageRepository; } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java index fe7e2bf015..c5ca894a00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ota/OtaPackageInfoRepository.java @@ -26,14 +26,15 @@ import org.thingsboard.server.dao.model.sql.OtaPackageInfoEntity; import java.util.UUID; public interface OtaPackageInfoRepository extends JpaRepository { - @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, CASE WHEN (f.data IS NOT NULL OR f.url IS NOT NULL) THEN true ELSE false END) FROM OtaPackageEntity f WHERE " + + + @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, f.externalId, CASE WHEN (f.data IS NOT NULL OR f.url IS NOT NULL) THEN true ELSE false END) FROM OtaPackageEntity f WHERE " + "f.tenantId = :tenantId " + "AND (:searchText IS NULL OR ilike(f.title, CONCAT('%', :searchText, '%')) = true)") Page findAllByTenantId(@Param("tenantId") UUID tenantId, @Param("searchText") String searchText, Pageable pageable); - @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, true) FROM OtaPackageEntity f WHERE " + + @Query("SELECT new OtaPackageInfoEntity(f.id, f.createdTime, f.tenantId, f.deviceProfileId, f.type, f.title, f.version, f.tag, f.url, f.fileName, f.contentType, f.checksumAlgorithm, f.checksum, f.dataSize, f.additionalInfo, f.externalId, true) FROM OtaPackageEntity f WHERE " + "f.tenantId = :tenantId " + "AND f.deviceProfileId = :deviceProfileId " + "AND f.type = :type " + @@ -45,7 +46,7 @@ public interface OtaPackageInfoRepository extends JpaRepository { +public interface OtaPackageRepository extends JpaRepository, ExportableEntityRepository { @Query(value = "SELECT COALESCE(SUM(ota.data_size), 0) FROM ota_package ota WHERE ota.tenant_id = :tenantId AND ota.data IS NOT NULL", nativeQuery = true) Long sumDataSizeByTenantId(@Param("tenantId") UUID tenantId); Page findByTenantId(UUID tenantId, Pageable pageable); + OtaPackageEntity findByTenantIdAndTitleAndVersion(UUID tenantId, String title, String version); + + @Query("SELECT externalId FROM OtaPackageEntity WHERE id = :id") + UUID getExternalIdById(@Param("id") UUID id); + + @Query("SELECT r.id FROM OtaPackageEntity r WHERE r.tenantId = :tenantId") + Page findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java index c27602421d..889de7cb74 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/AdminSettingsRepository.java @@ -22,9 +22,6 @@ import org.thingsboard.server.dao.model.sql.AdminSettingsEntity; import java.util.UUID; -/** - * Created by Valerii Sosliuk on 5/6/2017. - */ public interface AdminSettingsRepository extends JpaRepository { AdminSettingsEntity findByTenantIdAndKey(UUID tenantId, String key); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java index 68ce5e9d22..3dafa79c3f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/settings/JpaAdminSettingsDao.java @@ -15,12 +15,12 @@ */ package org.thingsboard.server.dao.sql.settings; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -35,21 +35,10 @@ import java.util.UUID; @Component @SqlDao -@Slf4j +@RequiredArgsConstructor public class JpaAdminSettingsDao extends JpaAbstractDao implements AdminSettingsDao, TenantEntityDao { - @Autowired - private AdminSettingsRepository adminSettingsRepository; - - @Override - protected Class getEntityClass() { - return AdminSettingsEntity.class; - } - - @Override - protected JpaRepository getRepository() { - return adminSettingsRepository; - } + private final AdminSettingsRepository adminSettingsRepository; @Override public AdminSettings findByTenantIdAndKey(UUID tenantId, String key) { @@ -77,4 +66,19 @@ public class JpaAdminSettingsDao extends JpaAbstractDao getEntityClass() { + return AdminSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return adminSettingsRepository; + } + + @Override + public EntityType getEntityType() { + return EntityType.ADMIN_SETTINGS; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index b9d09e55ba..0df7c36527 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -43,7 +43,6 @@ import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.service.validator.TenantDataValidator; -import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.trendz.TrendzSettingsService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; import org.thingsboard.server.dao.user.UserService; @@ -76,8 +75,6 @@ public class TenantServiceImpl extends AbstractCachedEntityService tenantDao.existsById(tenantId, tenantId.getId()), false); } - private PaginatedRemover tenantsRemover = new PaginatedRemover<>() { + private final PaginatedRemover tenantsRemover = new PaginatedRemover<>() { @Override protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { diff --git a/dao/src/main/resources/sql/schema-entities-idx.sql b/dao/src/main/resources/sql/schema-entities-idx.sql index ad311f00df..12f314590a 100644 --- a/dao/src/main/resources/sql/schema-entities-idx.sql +++ b/dao/src/main/resources/sql/schema-entities-idx.sql @@ -73,22 +73,6 @@ CREATE INDEX IF NOT EXISTS idx_edge_event_id ON edge_event(id); CREATE INDEX IF NOT EXISTS idx_rpc_tenant_id_device_id ON rpc(tenant_id, device_id); -CREATE INDEX IF NOT EXISTS idx_device_external_id ON device(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_device_profile_external_id ON device_profile(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_asset_external_id ON asset(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_entity_view_external_id ON entity_view(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_rule_chain_external_id ON rule_chain(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_dashboard_external_id ON dashboard(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_customer_external_id ON customer(tenant_id, external_id); - -CREATE INDEX IF NOT EXISTS idx_widgets_bundle_external_id ON widgets_bundle(tenant_id, external_id); - CREATE INDEX IF NOT EXISTS idx_rule_node_external_id ON rule_node(rule_chain_id, external_id); CREATE INDEX IF NOT EXISTS idx_rule_node_type_id_configuration_version ON rule_node(type, id, configuration_version); @@ -131,3 +115,5 @@ CREATE INDEX IF NOT EXISTS idx_resource_type_public_resource_key ON resource(res CREATE INDEX IF NOT EXISTS mobile_app_bundle_tenant_id ON mobile_app_bundle(tenant_id); CREATE INDEX IF NOT EXISTS idx_job_tenant_id ON job(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_ai_model_tenant_id ON ai_model(tenant_id); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index f2a0bc26c1..6ccf2f6d95 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -216,7 +216,9 @@ CREATE TABLE IF NOT EXISTS ota_package ( data oid, data_size bigint, additional_info varchar, - CONSTRAINT ota_package_tenant_title_version_unq_key UNIQUE (tenant_id, title, version) + external_id uuid, + CONSTRAINT ota_package_tenant_title_version_unq_key UNIQUE (tenant_id, title, version), + CONSTRAINT ota_package_external_id_unq_key UNIQUE (tenant_id, external_id) ); CREATE TABLE IF NOT EXISTS queue ( @@ -624,6 +626,7 @@ CREATE TABLE IF NOT EXISTS mobile_app ( created_time bigint NOT NULL, tenant_id uuid, pkg_name varchar(255), + title varchar(255), app_secret varchar(2048), platform_type varchar(32), status varchar(32), @@ -961,3 +964,15 @@ CREATE TABLE IF NOT EXISTS job ( configuration varchar NOT NULL, result varchar ); + +CREATE TABLE IF NOT EXISTS ai_model ( + id UUID NOT NULL PRIMARY KEY, + external_id UUID, + created_time BIGINT NOT NULL, + tenant_id UUID NOT NULL, + version BIGINT NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + configuration JSONB NOT NULL, + CONSTRAINT ai_model_name_unq_key UNIQUE (tenant_id, name), + CONSTRAINT ai_model_external_id_unq_key UNIQUE (tenant_id, external_id) +); diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java index d0af99021a..94292548e5 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/JdbcTemplateTest.java @@ -17,7 +17,7 @@ package org.thingsboard.server.dao.sql; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.dao.QueryTimeoutException; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; import org.springframework.test.context.TestPropertySource; import org.thingsboard.server.dao.AbstractJpaDaoTest; @@ -34,6 +34,6 @@ public class JdbcTemplateTest extends AbstractJpaDaoTest { @Test public void queryTimeoutTest() { - assertThrows(DataAccessResourceFailureException.class, () -> jdbcTemplate.query("SELECT pg_sleep(10)", rs -> {})); + assertThrows(QueryTimeoutException.class, () -> jdbcTemplate.query("SELECT pg_sleep(10)", rs -> {})); } } diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index a44303107c..1c2c0c5519 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -111,6 +111,9 @@ cache.specs.mobileSecretKey.maxSize=10000 cache.specs.trendzSettings.timeToLiveInMinutes=1440 cache.specs.trendzSettings.maxSize=10000 +cache.specs.aiModel.timeToLiveInMinutes=1440 +cache.specs.aiModel.maxSize=10000 + redis.connection.host=localhost redis.connection.port=6379 redis.connection.db=0 @@ -158,4 +161,4 @@ queue.core.poll-interval=5 queue.core.partitions=2 queue.rule-engine.poll-interval=5 -spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect \ No newline at end of file +spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect diff --git a/docker/docker-compose.cassandra.volumes.yml b/docker/docker-compose.cassandra.volumes.yml index c6d64c484e..8e92069a06 100644 --- a/docker/docker-compose.cassandra.volumes.yml +++ b/docker/docker-compose.cassandra.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: cassandra: volumes: diff --git a/docker/docker-compose.confluent.yml b/docker/docker-compose.confluent.yml index e832b72519..46cb0235ee 100644 --- a/docker/docker-compose.confluent.yml +++ b/docker/docker-compose.confluent.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-js-executor: env_file: diff --git a/docker/docker-compose.edqs.volumes.yml b/docker/docker-compose.edqs.volumes.yml index 3a45542b99..cebc7dd290 100644 --- a/docker/docker-compose.edqs.volumes.yml +++ b/docker/docker-compose.edqs.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-edqs1: volumes: diff --git a/docker/docker-compose.edqs.yml b/docker/docker-compose.edqs.yml index 21bace143a..3ebb526a2d 100644 --- a/docker/docker-compose.edqs.yml +++ b/docker/docker-compose.edqs.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-core1: env_file: diff --git a/docker/docker-compose.hybrid.yml b/docker/docker-compose.hybrid.yml index 3ced5385f1..6b4129ec56 100644 --- a/docker/docker-compose.hybrid.yml +++ b/docker/docker-compose.hybrid.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: postgres: restart: always diff --git a/docker/docker-compose.kafka.yml b/docker/docker-compose.kafka.yml index 7e6fcae09c..026070cc89 100644 --- a/docker/docker-compose.kafka.yml +++ b/docker/docker-compose.kafka.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: kafka: restart: always diff --git a/docker/docker-compose.postgres.volumes.yml b/docker/docker-compose.postgres.volumes.yml index 3d2e026ef4..696189a639 100644 --- a/docker/docker-compose.postgres.volumes.yml +++ b/docker/docker-compose.postgres.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: postgres: volumes: diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml index cd0373af42..bf43e1823d 100644 --- a/docker/docker-compose.postgres.yml +++ b/docker/docker-compose.postgres.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: postgres: restart: always diff --git a/docker/docker-compose.prometheus-grafana.yml b/docker/docker-compose.prometheus-grafana.yml index 81650c6b26..38699f1040 100644 --- a/docker/docker-compose.prometheus-grafana.yml +++ b/docker/docker-compose.prometheus-grafana.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - volumes: prometheus_data: {} grafana_data: {} diff --git a/docker/docker-compose.valkey-cluster.volumes.yml b/docker/docker-compose.valkey-cluster.volumes.yml index 7e78d04505..e8da9844d9 100644 --- a/docker/docker-compose.valkey-cluster.volumes.yml +++ b/docker/docker-compose.valkey-cluster.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: # Valkey cluster valkey-node-0: diff --git a/docker/docker-compose.valkey-cluster.yml b/docker/docker-compose.valkey-cluster.yml index 02757240dc..242fabf44b 100644 --- a/docker/docker-compose.valkey-cluster.yml +++ b/docker/docker-compose.valkey-cluster.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: # Valkey cluster # The latest version of Valkey compatible with ThingsBoard is 8.0 diff --git a/docker/docker-compose.valkey-sentinel.volumes.yml b/docker/docker-compose.valkey-sentinel.volumes.yml index 0f53ee005d..3f4a09a912 100644 --- a/docker/docker-compose.valkey-sentinel.volumes.yml +++ b/docker/docker-compose.valkey-sentinel.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: # Valkey sentinel valkey-primary: diff --git a/docker/docker-compose.valkey-sentinel.yml b/docker/docker-compose.valkey-sentinel.yml index d4b791643a..3827e0358b 100644 --- a/docker/docker-compose.valkey-sentinel.yml +++ b/docker/docker-compose.valkey-sentinel.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: # Valkey sentinel # The latest version of Valkey compatible with ThingsBoard is 8.0 diff --git a/docker/docker-compose.valkey.volumes.yml b/docker/docker-compose.valkey.volumes.yml index 7da5f154a6..02a7c397a3 100644 --- a/docker/docker-compose.valkey.volumes.yml +++ b/docker/docker-compose.valkey.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: valkey: volumes: diff --git a/docker/docker-compose.valkey.yml b/docker/docker-compose.valkey.yml index 6b8cf3feb3..d4a620820d 100644 --- a/docker/docker-compose.valkey.yml +++ b/docker/docker-compose.valkey.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: # Valkey standalone # The latest version of Valkey compatible with ThingsBoard is 8.0 diff --git a/docker/docker-compose.volumes.yml b/docker/docker-compose.volumes.yml index 06a904e7fc..a01247c25d 100644 --- a/docker/docker-compose.volumes.yml +++ b/docker/docker-compose.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: tb-core1: volumes: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1cee5ad5ad..224f94c103 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,9 +14,6 @@ # limitations under the License. # - -version: '3.0' - services: zookeeper: restart: always diff --git a/docker/queue-confluent.env b/docker/queue-confluent.env index 868a135de3..900504c4ea 100644 --- a/docker/queue-confluent.env +++ b/docker/queue-confluent.env @@ -15,4 +15,4 @@ TB_QUEUE_KAFKA_RE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800 TB_QUEUE_KAFKA_CORE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 TB_QUEUE_KAFKA_TA_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 TB_QUEUE_KAFKA_NOTIFICATIONS_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:1048576000 -TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES=retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600 +TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES=retention.ms:86400000;segment.bytes:52428800;retention.bytes:104857600 diff --git a/edqs/pom.xml b/edqs/pom.xml index ada51eff8e..c29710490b 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard edqs diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java index 1f90babf01..7444d24b99 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/AssetSearchQueryFilterTest.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -132,7 +133,7 @@ public class AssetSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List assetTypes) { AssetSearchQueryFilter filter = new AssetSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setAssetTypes(assetTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java index b3f15c2f61..9e30997b88 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceSearchQueryFilterTest.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -135,7 +136,7 @@ public class DeviceSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List deviceTypes) { DeviceSearchQueryFilter filter = new DeviceSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setDeviceTypes(deviceTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java index f0911570ea..42c64ed602 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EdgeSearchQueryFilterTest.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EdgeSearchQueryFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -101,7 +102,7 @@ public class EdgeSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List edgeTypes) { EdgeSearchQueryFilter filter = new EdgeSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setEdgeTypes(edgeTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java index 8fab142dc7..e7fc4419ef 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityTypeFilterTest.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -36,7 +37,9 @@ import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.EntityTypeFilter; import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; +import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate; +import org.thingsboard.server.edqs.util.RepositoryUtils; import java.util.Arrays; import java.util.List; @@ -61,6 +64,10 @@ public class EntityTypeFilterTest extends AbstractEDQTest { addOrUpdate(new LatestTsKv(device.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "enabled")), 0L)); addOrUpdate(new LatestTsKv(device2.getId(), new BasicTsKvEntry(43, new StringDataEntry("state", "disabled")), 0L)); addOrUpdate(new LatestTsKv(device3.getId(), new BasicTsKvEntry(43, new BooleanDataEntry("free", true)), 0L)); + + addOrUpdate(new LatestTsKv(device.getId(), new BasicTsKvEntry(43, new StringDataEntry("temperature", "26.0")), 0L)); + addOrUpdate(new LatestTsKv(device2.getId(), new BasicTsKvEntry(43, new DoubleDataEntry("temperature", 25.0)), 0L)); + addOrUpdate(new LatestTsKv(device3.getId(), new BasicTsKvEntry(43, new DoubleDataEntry("temperature", 19.0)), 0L)); } @After @@ -87,6 +94,11 @@ public class EntityTypeFilterTest extends AbstractEDQTest { // find asset entities result = repository.findEntityDataByQuery(tenantId, null, getEntityTypeQuery(EntityType.ASSET, null), false); Assert.assertEquals(0, result.getTotalElements()); + + // find all tenant devices with filter by temperature + KeyFilter tempFilter = getTemperatureFilter(NumericFilterPredicate.NumericOperation.GREATER_OR_EQUAL, 20.0); + result = repository.findEntityDataByQuery(tenantId, null, getEntityTypeQuery(EntityType.DEVICE, List.of(tempFilter)), false); + Assert.assertEquals(2, result.getTotalElements()); } @Test @@ -143,4 +155,15 @@ public class EntityTypeFilterTest extends AbstractEDQTest { return nameFilter; } + private static KeyFilter getTemperatureFilter(NumericFilterPredicate.NumericOperation operation, Double predicateValue) { + KeyFilter tempFilter = new KeyFilter(); + tempFilter.setKey(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); + var predicate = new NumericFilterPredicate(); + predicate.setOperation(operation); + predicate.setValue(new FilterPredicateValue<>(predicateValue)); + tempFilter.setPredicate(predicate); + tempFilter.setValueType(EntityKeyValueType.NUMERIC); + return tempFilter; + } + } diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java index fb32759045..c484ef78ae 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/EntityViewSearchQueryFilterTest.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -115,7 +116,7 @@ public class EntityViewSearchQueryFilterTest extends AbstractEDQTest { private PageData findData(CustomerId customerId, EntityId rootId, EntitySearchDirection direction, String relationType, int maxLevel, boolean lastLevelOnly, List entityViewTypes) { EntityViewSearchQueryFilter filter = new EntityViewSearchQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setDirection(direction); filter.setRelationType(relationType); filter.setEntityViewTypes(entityViewTypes); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java index 094d46f977..a376e3f672 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RelationsQueryFilterTest.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -146,7 +147,7 @@ public class RelationsQueryFilterTest extends AbstractEDQTest { private PageData filter(CustomerId customerId, EntityId rootId, int maxLevel, boolean lastLevelOnly, RelationEntityTypeFilter... relationEntityTypeFilters) { RelationsQueryFilter filter = new RelationsQueryFilter(); - filter.setRootEntity(rootId); + filter.setRootEntity(AliasEntityId.fromEntityId(rootId)); filter.setFilters(Arrays.asList(relationEntityTypeFilters)); filter.setDirection(EntitySearchDirection.FROM); filter.setFetchLastLevelOnly(lastLevelOnly); diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java index 133816f576..93786553c5 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/SingleEntityFilterTest.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.AliasEntityId; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -113,7 +114,7 @@ public class SingleEntityFilterTest extends AbstractEDQTest { private static EntityDataQuery getEntityDataQuery(DeviceId deviceId) { SingleEntityFilter filter = new SingleEntityFilter(); - filter.setSingleEntity(deviceId); + filter.setSingleEntity(AliasEntityId.fromEntityId(deviceId)); var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); diff --git a/monitoring/pom.xml b/monitoring/pom.xml index d72efb3aa6..cee8fc0a6b 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 5890564a1f..84509ba48f 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java index 3a337dbb8b..77e80f25ac 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -38,6 +38,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; import static org.testng.Assert.fail; +import static org.thingsboard.server.msa.TestUtils.addComposeVersion; @Slf4j public class ContainerTestSuite { @@ -53,7 +54,7 @@ public class ContainerTestSuite { private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*"; private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400); - private DockerComposeContainer testContainer; + private DockerComposeContainerImpl testContainer; private ThingsBoardDbInstaller installTb; private boolean isActive; @@ -78,8 +79,6 @@ public class ContainerTestSuite { } public void start() { - installTb = new ThingsBoardDbInstaller(); - installTb.createVolumes(); log.info("System property of blackBoxTests.redisCluster is {}", IS_VALKEY_CLUSTER); log.info("System property of blackBoxTests.redisSentinel is {}", IS_VALKEY_SENTINEL); log.info("System property of blackBoxTests.redisSsl is {}", IS_VALKEY_SSL); @@ -93,17 +92,8 @@ public class ContainerTestSuite { FileUtils.copyDirectory(new File("src/test/resources"), new File(targetDir)); - class DockerComposeContainerImpl> extends DockerComposeContainer { - public DockerComposeContainerImpl(List composeFiles) { - super(composeFiles); - } - - @Override - public void stop() { - super.stop(); - tryDeleteDir(targetDir); - } - } + installTb = new ThingsBoardDbInstaller(targetDir); + installTb.createVolumes(); if (IS_VALKEY_SSL) { addToFile(targetDir, "cache-valkey.env", @@ -132,7 +122,9 @@ public class ContainerTestSuite { composeFiles.add(new File(targetDir + "docker-compose.cassandra.volumes.yml")); } - testContainer = new DockerComposeContainerImpl<>(composeFiles) + addComposeVersion(composeFiles, "3.0"); + + testContainer = new DockerComposeContainerImpl(targetDir, composeFiles) .withPull(false) .withLocalCompose(true) .withOptions("--compatibility") @@ -194,7 +186,8 @@ public class ContainerTestSuite { public void stop() { if (isActive) { testContainer.stop(); - installTb.savaLogsAndRemoveVolumes(); + installTb.saveLogsAndRemoveVolumes(); + testContainer.cleanup(); setActive(false); } } @@ -261,4 +254,23 @@ public class ContainerTestSuite { public DockerComposeContainer getTestContainer() { return testContainer; } + + static class DockerComposeContainerImpl extends DockerComposeContainer { + + private final String targetDir; + + public DockerComposeContainerImpl(String targetDir, List composeFiles) { + super(composeFiles); + this.targetDir = targetDir; + } + + @Override + public void stop() { + super.stop(); + } + + public void cleanup() { + tryDeleteDir(this.targetDir); + } + } } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestUtils.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestUtils.java new file mode 100644 index 0000000000..e70e020e88 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/TestUtils.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +public class TestUtils { + + public static void addComposeVersion(List composeFiles, String version) throws IOException { + for (File composeFile : composeFiles) { + addComposeVersion(composeFile, version); + } + } + + public static void addComposeVersion(File composeFile, String version) throws IOException { + Path composeFilePath = composeFile.toPath(); + String data = Files.readString(composeFilePath); + String versionString = "version: '" + version + "'"; + if (!data.contains(versionString)) { + data += "\n" + versionString + "\n"; + } + Files.writeString(composeFilePath, data); + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java index 3c57e08487..72be05922b 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -20,6 +20,7 @@ import org.testcontainers.utility.Base58; import org.thingsboard.server.common.data.StringUtils; import java.io.File; +import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; @@ -29,6 +30,8 @@ import java.util.StringJoiner; import java.util.stream.Collectors; import java.util.stream.IntStream; +import static org.thingsboard.server.msa.TestUtils.addComposeVersion; + @Slf4j public class ThingsBoardDbInstaller { @@ -53,6 +56,7 @@ public class ThingsBoardDbInstaller { private final DockerComposeExecutor dockerCompose; + private final String targetDir; private final String postgresDataVolume; private final String cassandraDataVolume; @@ -69,27 +73,30 @@ public class ThingsBoardDbInstaller { private final String tbEdqsLogVolume; private final Map env; - public ThingsBoardDbInstaller() { + public ThingsBoardDbInstaller(String targetDir) throws IOException { + this.targetDir = targetDir; log.info("System property of blackBoxTests.redisCluster is {}", IS_VALKEY_CLUSTER); - log.info("System property of blackBoxTests.redisCluster is {}", IS_VALKEY_SENTINEL); + log.info("System property of blackBoxTests.redisSentinel is {}", IS_VALKEY_SENTINEL); log.info("System property of blackBoxTests.hybridMode is {}", IS_HYBRID_MODE); List composeFiles = new ArrayList<>(Arrays.asList( - new File("./../../docker/docker-compose.yml"), - new File("./../../docker/docker-compose.volumes.yml"), + new File(targetDir + "docker-compose.yml"), + new File(targetDir + "docker-compose.volumes.yml"), IS_HYBRID_MODE - ? new File("./../../docker/docker-compose.hybrid.yml") - : new File("./../../docker/docker-compose.postgres.yml"), - new File("./../../docker/docker-compose.postgres.volumes.yml"), - resolveValkeyComposeFile(), - resolveValkeyComposeVolumesFile() + ? new File(targetDir + "docker-compose.hybrid.yml") + : new File(targetDir + "docker-compose.postgres.yml"), + new File(targetDir + "docker-compose.postgres.volumes.yml"), + resolveValkeyComposeFile(targetDir), + resolveValkeyComposeVolumesFile(targetDir) )); if (IS_HYBRID_MODE) { - composeFiles.add(new File("./../../docker/docker-compose.cassandra.volumes.yml")); - composeFiles.add(new File("src/test/resources/docker-compose.hybrid-test-extras.yml")); + composeFiles.add(new File(targetDir + "docker-compose.cassandra.volumes.yml")); + composeFiles.add(new File(targetDir + "docker-compose.hybrid-test-extras.yml")); } else { - composeFiles.add(new File("src/test/resources/docker-compose.postgres-test-extras.yml")); + composeFiles.add(new File(targetDir + "docker-compose.postgres-test-extras.yml")); } + addComposeVersion(composeFiles, "3.0"); + String identifier = Base58.randomString(6).toLowerCase(); String project = identifier + Base58.randomString(6).toLowerCase(); @@ -137,24 +144,24 @@ public class ThingsBoardDbInstaller { dockerCompose.withEnv(env); } - private static File resolveValkeyComposeVolumesFile() { + private static File resolveValkeyComposeVolumesFile(String targetDir) { if (IS_VALKEY_CLUSTER) { - return new File("./../../docker/docker-compose.valkey-cluster.volumes.yml"); + return new File(targetDir + "docker-compose.valkey-cluster.volumes.yml"); } if (IS_VALKEY_SENTINEL) { - return new File("./../../docker/docker-compose.valkey-sentinel.volumes.yml"); + return new File(targetDir + "docker-compose.valkey-sentinel.volumes.yml"); } - return new File("./../../docker/docker-compose.valkey.volumes.yml"); + return new File(targetDir + "docker-compose.valkey.volumes.yml"); } - private static File resolveValkeyComposeFile() { + private static File resolveValkeyComposeFile(String targetDir) { if (IS_VALKEY_CLUSTER) { - return new File("./../../docker/docker-compose.valkey-cluster.yml"); + return new File(targetDir + "docker-compose.valkey-cluster.yml"); } if (IS_VALKEY_SENTINEL) { - return new File("./../../docker/docker-compose.valkey-sentinel.yml"); + return new File(targetDir + "docker-compose.valkey-sentinel.yml"); } - return new File("./../../docker/docker-compose.valkey.yml"); + return new File(targetDir + "docker-compose.valkey.yml"); } public Map getEnv() { @@ -240,7 +247,7 @@ public class ThingsBoardDbInstaller { } } - public void savaLogsAndRemoveVolumes() { + public void saveLogsAndRemoveVolumes() { copyLogs(tbLogVolume, "./target/tb-logs/"); copyLogs(tbCoapTransportLogVolume, "./target/tb-coap-transport-logs/"); copyLogs(tbLwm2mTransportLogVolume, "./target/tb-lwm2m-transport-logs/"); diff --git a/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml b/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml index 0d527e9eb5..c213a2cb57 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.hybrid-test-extras.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: cassandra: environment: diff --git a/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml b/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml index ccc3f34599..cd09476924 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.mosquitto.yml @@ -14,7 +14,6 @@ # limitations under the License. # -version: '3.0' services: broker: image: eclipse-mosquitto diff --git a/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml b/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml index f88f3543fb..42b3f144a2 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.postgres-test-extras.yml @@ -14,6 +14,4 @@ # limitations under the License. # -version: '3.0' - # Placeholder diff --git a/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml b/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml index 309bb6c0fa..0c89d2d6e7 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.rabbitmq-server.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: rabbitmq: restart: always diff --git a/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml index 7da5f154a6..02a7c397a3 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.volumes.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: valkey: volumes: diff --git a/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.yml b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.yml index 73de64b303..8c3029cda1 100644 --- a/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.yml +++ b/msa/black-box-tests/src/test/resources/docker-compose.valkey-ssl.yml @@ -14,8 +14,6 @@ # limitations under the License. # -version: '3.0' - services: # Valkey standalone # The latest version of Valkey compatible with ThingsBoard is 8.0 diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 3c63c44d32..d1f8291e2e 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index 1c16eaa594..4d417081d8 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.1.0", + "version": "4.2.0", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 4cdb487968..6730da74e6 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index 02390df0e8..61f492dca8 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa diff --git a/msa/pom.xml b/msa/pom.xml index 97bbed14b6..58482a32cf 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index d81143b3d9..5f53d868c9 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 03481f16aa..646b03bf14 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 0e0aa13d76..4f510f937b 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 81a0c40519..d9159cd0db 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 7fa1d161bf..9972d66a18 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 9c70a376c8..185addeb3b 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index ae99b7b515..193d01ef06 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index 73c9a320f0..ea3d6847c0 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.1.0-RC + 4.2.0-RC org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 0aab19bfa1..a77eb1d206 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 3a59886d9b..0e91749cbe 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index 8df71fc347..de0c04fc9d 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.1.0", + "version": "4.2.0", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index fc244b80dd..da7215c5c5 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index fc07fa42fe..bbcfcfea18 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard netty-mqtt - 4.1.0-RC + 4.2.0-RC jar Netty MQTT Client diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java index 0b50b1883a..a1987cfe98 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/ChannelClosedException.java @@ -15,11 +15,11 @@ */ package org.thingsboard.mqtt; -/** - * Created by Valerii Sosliuk on 12/26/2017. - */ +import java.io.Serial; + public class ChannelClosedException extends RuntimeException { + @Serial private static final long serialVersionUID = 6266638352424706909L; public ChannelClosedException() { @@ -40,4 +40,5 @@ public class ChannelClosedException extends RuntimeException { public ChannelClosedException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java index 85b4499e36..ae5168e466 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientCallback.java @@ -21,9 +21,6 @@ import io.netty.handler.codec.mqtt.MqttPubAckMessage; import io.netty.handler.codec.mqtt.MqttSubAckMessage; import io.netty.handler.codec.mqtt.MqttUnsubAckMessage; -/** - * Created by Valerii Sosliuk on 12/30/2017. - */ public interface MqttClientCallback { /** @@ -53,4 +50,5 @@ public interface MqttClientCallback { default void onDisconnect(MqttMessage mqttDisconnectMessage) { } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java index 24feb3e58e..e2b6967f06 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java @@ -28,23 +28,46 @@ import java.util.Random; @SuppressWarnings({"WeakerAccess", "unused"}) public final class MqttClientConfig { + + @Getter private final SslContext sslContext; private final String randomClientId; @Getter @Setter private String ownerId; // [TenantId][IntegrationId] or [TenantId][RuleNodeId] for exceptions logging purposes + @Nonnull + @Getter private String clientId; + @Getter private int timeoutSeconds = 60; + @Getter private MqttVersion protocolVersion = MqttVersion.MQTT_3_1; - @Nullable private String username = null; - @Nullable private String password = null; + @Nullable + @Getter + @Setter + private String username = null; + @Nullable + @Getter + @Setter + private String password = null; + @Getter + @Setter private boolean cleanSession = true; - @Nullable private MqttLastWill lastWill; + @Nullable + @Getter + @Setter + private MqttLastWill lastWill; + @Setter + @Getter private Class channelClass = NioSocketChannel.class; + @Getter + @Setter private boolean reconnect = true; + @Getter private long reconnectDelay = 1L; + @Getter private int maxBytesInMessage = 8092; @Getter @@ -74,109 +97,37 @@ public final class MqttClientConfig { public MqttClientConfig(SslContext sslContext) { this.sslContext = sslContext; Random random = new Random(); - String id = "netty-mqtt/"; + StringBuilder id = new StringBuilder("netty-mqtt/"); String[] options = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".split(""); - for(int i = 0; i < 8; i++){ - id += options[random.nextInt(options.length)]; + for (int i = 0; i < 8; i++) { + id.append(options[random.nextInt(options.length)]); } - this.clientId = id; - this.randomClientId = id; - } - - @Nonnull - public String getClientId() { - return clientId; + this.clientId = id.toString(); + this.randomClientId = id.toString(); } public void setClientId(@Nullable String clientId) { - if(clientId == null){ + if (clientId == null) { this.clientId = randomClientId; - }else{ + } else { this.clientId = clientId; } } - public int getTimeoutSeconds() { - return timeoutSeconds; - } - public void setTimeoutSeconds(int timeoutSeconds) { - if(timeoutSeconds != -1 && timeoutSeconds <= 0){ + if (timeoutSeconds != -1 && timeoutSeconds <= 0) { throw new IllegalArgumentException("timeoutSeconds must be > 0 or -1"); } this.timeoutSeconds = timeoutSeconds; } - public MqttVersion getProtocolVersion() { - return protocolVersion; - } - public void setProtocolVersion(MqttVersion protocolVersion) { - if(protocolVersion == null){ + if (protocolVersion == null) { throw new NullPointerException("protocolVersion"); } this.protocolVersion = protocolVersion; } - @Nullable - public String getUsername() { - return username; - } - - public void setUsername(@Nullable String username) { - this.username = username; - } - - @Nullable - public String getPassword() { - return password; - } - - public void setPassword(@Nullable String password) { - this.password = password; - } - - public boolean isCleanSession() { - return cleanSession; - } - - public void setCleanSession(boolean cleanSession) { - this.cleanSession = cleanSession; - } - - @Nullable - public MqttLastWill getLastWill() { - return lastWill; - } - - public void setLastWill(@Nullable MqttLastWill lastWill) { - this.lastWill = lastWill; - } - - public Class getChannelClass() { - return channelClass; - } - - public void setChannelClass(Class channelClass) { - this.channelClass = channelClass; - } - - public SslContext getSslContext() { - return sslContext; - } - - public boolean isReconnect() { - return reconnect; - } - - public void setReconnect(boolean reconnect) { - this.reconnect = reconnect; - } - - public long getReconnectDelay() { - return reconnectDelay; - } - /** * Sets the reconnect delay in seconds. Defaults to 1 second. * @param reconnectDelay @@ -189,10 +140,6 @@ public final class MqttClientConfig { this.reconnectDelay = reconnectDelay; } - public int getMaxBytesInMessage() { - return maxBytesInMessage; - } - /** * Sets the maximum number of bytes in the message for the {@link io.netty.handler.codec.mqtt.MqttDecoder}. * Default value is 8092 as specified by Netty. The absolute maximum size is 256MB as set by the MQTT spec. @@ -206,4 +153,5 @@ public final class MqttClientConfig { } this.maxBytesInMessage = maxBytesInMessage; } + } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java index 69d2b6e192..f7901b4bb5 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java @@ -46,7 +46,9 @@ import io.netty.handler.timeout.IdleStateHandler; import io.netty.util.concurrent.DefaultPromise; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.Promise; +import lombok.AccessLevel; import lombok.Getter; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.ListeningExecutor; @@ -67,18 +69,28 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j final class MqttClientImpl implements MqttClient { + @Getter(AccessLevel.PACKAGE) private final Set serverSubscriptions = new HashSet<>(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap pendingServerUnsubscribes = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap qos2PendingIncomingPublishes = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap pendingPublishes = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final HashMultimap subscriptions = HashMultimap.create(); + @Getter(AccessLevel.PACKAGE) private final ConcurrentMap pendingSubscriptions = new ConcurrentHashMap<>(); + @Getter(AccessLevel.PACKAGE) private final Set pendingSubscribeTopics = new HashSet<>(); + @Getter(AccessLevel.PACKAGE) private final HashMultimap handlerToSubscription = HashMultimap.create(); private final AtomicInteger nextMessageId = new AtomicInteger(1); + @Getter private final MqttClientConfig clientConfig; + @Getter(AccessLevel.PACKAGE) private final MqttHandler defaultHandler; private final ReconnectStrategy reconnectStrategy; @@ -88,12 +100,15 @@ final class MqttClientImpl implements MqttClient { private volatile Channel channel; private volatile boolean disconnected = false; + @Getter private volatile boolean reconnect = false; private String host; private int port; @Getter + @Setter private MqttClientCallback callback; + @Getter private final ListeningExecutor handlerExecutor; private final static int DISCONNECT_FALLBACK_DELAY_SECS = 1; @@ -240,11 +255,6 @@ final class MqttClientImpl implements MqttClient { this.eventLoop = eventLoop; } - @Override - public ListeningExecutor getHandlerExecutor() { - return this.handlerExecutor; - } - /** * Subscribe on the given topic. When a message is received, MqttClient will invoke the {@link MqttHandler#onMessage(String, ByteBuf)} function of the given handler * @@ -446,59 +456,38 @@ final class MqttClientImpl implements MqttClient { return future; } - /** - * Retrieve the MqttClient configuration - * - * @return The {@link MqttClientConfig} instance we use - */ - @Override - public MqttClientConfig getClientConfig() { - return clientConfig; - } - @Override public void disconnect() { if (disconnected) { return; } + disconnected = true; log.trace("[{}] Disconnecting from server", channel != null ? channel.id() : "UNKNOWN"); if (this.channel != null) { MqttMessage message = new MqttMessage(new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0)); sendAndFlushPacket(message).addListener((ChannelFutureListener) future -> { future.channel().close(); - disconnected = true; }); eventLoop.schedule(() -> { if (channel.isOpen()) { log.trace("[{}] Channel still open after {} second; forcing close now", channel.id(), DISCONNECT_FALLBACK_DELAY_SECS); this.channel.close(); - disconnected = true; } }, DISCONNECT_FALLBACK_DELAY_SECS, TimeUnit.SECONDS); } } - @Override - public void setCallback(MqttClientCallback callback) { - this.callback = callback; - } - ///////////////////////////////////////////// PRIVATE API ///////////////////////////////////////////// - public boolean isReconnect() { - return reconnect; - } - public void onSuccessfulReconnect() { if (callback != null) { callback.onSuccessfulReconnect(); } } - ChannelFuture sendAndFlushPacket(Object message) { if (this.channel == null) { return null; @@ -576,7 +565,7 @@ final class MqttClientImpl implements MqttClient { } private void checkSubscriptions(String topic, Promise promise) { - if (!(this.subscriptions.containsKey(topic) && this.subscriptions.get(topic).size() != 0) && this.serverSubscriptions.contains(topic)) { + if (!(this.subscriptions.containsKey(topic) && !this.subscriptions.get(topic).isEmpty()) && this.serverSubscriptions.contains(topic)) { MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.UNSUBSCRIBE, false, MqttQoS.AT_LEAST_ONCE, false, 0); MqttMessageIdVariableHeader variableHeader = getNewMessageId(); MqttUnsubscribePayload payload = new MqttUnsubscribePayload(Collections.singletonList(topic)); @@ -614,38 +603,6 @@ final class MqttClientImpl implements MqttClient { } } - ConcurrentMap getPendingSubscriptions() { - return pendingSubscriptions; - } - - HashMultimap getSubscriptions() { - return subscriptions; - } - - Set getPendingSubscribeTopics() { - return pendingSubscribeTopics; - } - - HashMultimap getHandlerToSubscription() { - return handlerToSubscription; - } - - Set getServerSubscriptions() { - return serverSubscriptions; - } - - ConcurrentMap getPendingServerUnsubscribes() { - return pendingServerUnsubscribes; - } - - ConcurrentMap getPendingPublishes() { - return pendingPublishes; - } - - ConcurrentMap getQos2PendingIncomingPublishes() { - return qos2PendingIncomingPublishes; - } - private class MqttChannelInitializer extends ChannelInitializer { private final Promise connectFuture; @@ -673,10 +630,7 @@ final class MqttClientImpl implements MqttClient { ch.pipeline().addLast("mqttPingHandler", new MqttPingHandler(MqttClientImpl.this.clientConfig.getTimeoutSeconds())); ch.pipeline().addLast("mqttHandler", new MqttChannelHandler(MqttClientImpl.this, connectFuture)); } - } - MqttHandler getDefaultHandler() { - return defaultHandler; } } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java index 67757d2a7a..909955d062 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java @@ -17,14 +17,18 @@ package org.thingsboard.mqtt; import io.netty.channel.ChannelFuture; import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import lombok.Getter; import lombok.ToString; @ToString @SuppressWarnings({"WeakerAccess", "unused"}) public final class MqttConnectResult { + @Getter private final boolean success; + @Getter private final MqttConnectReturnCode returnCode; + @Getter private final ChannelFuture closeFuture; MqttConnectResult(boolean success, MqttConnectReturnCode returnCode, ChannelFuture closeFuture) { @@ -33,16 +37,4 @@ public final class MqttConnectResult { this.closeFuture = closeFuture; } - public boolean isSuccess() { - return success; - } - - public MqttConnectReturnCode getReturnCode() { - return returnCode; - } - - public ChannelFuture getCloseFuture() { - return closeFuture; - } - } diff --git a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java index 7ad93462ab..d5125757da 100644 --- a/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java +++ b/netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttSubscription.java @@ -15,16 +15,23 @@ */ package org.thingsboard.mqtt; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; + import java.util.regex.Pattern; final class MqttSubscription { + @Getter(AccessLevel.PACKAGE) private final String topic; private final Pattern topicRegex; + @Getter private final MqttHandler handler; - + @Getter(AccessLevel.PACKAGE) private final boolean once; - + @Getter(AccessLevel.PACKAGE) + @Setter(AccessLevel.PACKAGE) private volatile boolean called; MqttSubscription(String topic, MqttHandler handler, boolean once) { @@ -40,22 +47,6 @@ final class MqttSubscription { this.topicRegex = Pattern.compile(topic.replace("+", "[^/]+").replace("#", ".+") + "$"); } - String getTopic() { - return topic; - } - - public MqttHandler getHandler() { - return handler; - } - - boolean isOnce() { - return once; - } - - boolean isCalled() { - return called; - } - boolean matches(String topic) { return this.topicRegex.matcher(topic).matches(); } @@ -78,7 +69,4 @@ final class MqttSubscription { return result; } - void setCalled(boolean called) { - this.called = called; - } } diff --git a/packaging/java/build.gradle b/packaging/java/build.gradle index 19cbeb126e..499c34c412 100644 --- a/packaging/java/build.gradle +++ b/packaging/java/build.gradle @@ -92,7 +92,7 @@ buildRpm { archiveVersion = projectVersion.replace('-', '') archiveFileName = "${pkgName}.rpm" - requires("java-17") + requires("(java-17 or java-17-headless or jre-17 or jre-17-headless)") // .or() notation does work in RPM plugin from("${buildDir}/conf") { include "${pkgName}.conf" diff --git a/pom.xml b/pom.xml index 3e59260543..a20594e4c7 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.1.0-RC + 4.2.0-RC pom Thingsboard @@ -38,76 +38,48 @@ ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.0.0 - 4.0.2 + 3.4.8 2.4.0-b180830.0359 - 4.0.5 - 10.1.42 - 2.5.2 - 3.2.12 - 3.2.12 - 3.2.12 - 6.1.21 - 6.2.11 - 6.3.9 5.1.5 0.12.5 - 2.0.17 - 2.24.3 - 1.5.6 0.10 4.17.0 4.2.25 5.0.4 33.1.0-jre - 3.1.8 - 3.14.0 - 1.16.1 + 3.18.0 2.16.1 1.3.1 1.10.0 - 5.3.1 - 5.2.4 + 10.0.2 4.5.14 - 4.4.16 2.12.7 - 2.17.2 - 2.17.2 - 1.7.0 4.4.0 1.5.6 0.6.12 3.12.1 2.0.0-M15 - 2.10.1 - 2.3.32 2.0.1 5.6.0 3.9.3 3.25.5 - 1.63.0 - 1.2.6 - 1.18.32 + 1.68.1 + 1.2.8 + 1.18.38 1.2.5 1.2.5 - 4.1.119.Final - 2.0.65.Final - 1.1.18 1.7.1 - 5.21.0 3.2.5 3.4.0 - 2.4.0TB - 2.2.21 + 2.8.8TB + 2.2.30 0.8 1.19.0 1.78.1 2.0.1 - 42.7.7 org/thingsboard/server/gen/**/*, org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* - 8.13.2 0.4.5 15.4 4.0.2 - 3.0.2 1.7.5 3.8.0 - 2.9.0 + 1.1.0 + 2.38.0 + 1.24 + 1.11.0 + 3.49.3 + 0.27.0 + 1.7.0 - 4.2.1 2.7.3 1.5.6 - 5.10.5 5.15.0 1.3.0 1.2.7 5.0.0 7.10.1 - 3.25.3 - 5.4.0 - 2.2 - 1.20.4 - 1.0.1 + 1.20.6 + 1.0.2 1.12 - 4.19.1 5.8.0 2.27.0 2.12.0 @@ -919,6 +895,21 @@ + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + dev.langchain4j + langchain4j-bom + ${langchain4j.version} + pom + import + + org.thingsboard netty-mqtt @@ -1134,100 +1125,11 @@ test-jar test - - jakarta.annotation - jakarta.annotation-api - ${jakarta-annotation.version} - - - jakarta.xml.bind - jakarta.xml.bind-api - ${jakarta.xml.bind-api.version} - javax.xml.bind jaxb-api ${javax.xml.bind-api.version} - - org.glassfish.jaxb - jaxb-runtime - ${jaxb-runtime.version} - - - org.apache.tomcat.embed - tomcat-embed-core - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-el - ${tomcat.version} - - - org.apache.tomcat.embed - tomcat-embed-websocket - ${tomcat.version} - - - - net.minidev - json-smart - ${net.minidev.json-smart} - - - - org.springframework.boot - spring-boot-starter - ${spring-boot.version} - - - org.springframework.boot - spring-boot-starter-security - ${spring-boot.version} - - - org.springframework.security - spring-security-oauth2-client - ${spring-security.version} - - - org.springframework.security - spring-security-oauth2-jose - ${spring-security.version} - - - - org.springframework.security - spring-security-config - ${spring-security.version} - - - org.springframework.security - spring-security-web - ${spring-security.version} - - - - org.springframework - spring-core - ${spring.version} - - - org.springframework.boot - spring-boot-starter-web - ${spring-boot.version} - - - org.springframework.boot - spring-boot-starter-websocket - ${spring-boot.version} - - - org.springframework.boot - spring-boot-autoconfigure - ${spring-boot.version} - org.springframework.boot spring-boot-starter-test @@ -1240,62 +1142,11 @@ - - org.springframework.boot - spring-boot-starter-data-jpa - ${spring-boot.version} - - - org.springframework.data - spring-data-commons - ${spring-data.version} - - - org.springframework.boot - spring-boot-starter-webflux - ${spring-boot.version} - - - io.projectreactor.netty - reactor-netty-http - ${reactor-netty.version} - org.apache.kafka kafka-clients ${kafka.version} - - org.postgresql - postgresql - ${postgresql.driver.version} - - - org.springframework - spring-context - ${spring.version} - - - org.springframework - spring-context-support - ${spring.version} - - - org.springframework - spring-tx - ${spring.version} - - - org.springframework - spring-web - ${spring.version} - - - org.springframework.security - spring-security-test - ${spring-security.version} - test - com.github.springtestdbunit spring-test-dbunit @@ -1307,11 +1158,6 @@ jjwt ${jjwt.version} - - org.freemarker - freemarker - ${freemarker.version} - org.yaml snakeyaml @@ -1322,11 +1168,6 @@ antlr ${antlr.version} - - com.rabbitmq - amqp-client - ${rabbitmq.version} - com.sun.mail jakarta.mail @@ -1354,150 +1195,16 @@ - - com.jayway.jsonpath - json-path - ${json-path.version} - - - com.jayway.jsonpath - json-path-assert - ${json-path.version} - test - - - io.netty - netty-all - ${netty.version} - - - io.netty - netty-tcnative-boringssl-static - ${netty-tcnative.version} - - - io.netty - netty-tcnative-classes - ${netty-tcnative.version} - - - io.netty - netty-buffer - ${netty.version} - - - io.netty - netty-codec - ${netty.version} - - - io.netty - netty-codec-dns - ${netty.version} - - - io.netty - netty-codec-http - ${netty.version} - - - io.netty - netty-codec-http2 - ${netty.version} - - - io.netty - netty-codec-mqtt - ${netty.version} - - - io.netty - netty-codec-socks - ${netty.version} - - - io.netty - netty-common - ${netty.version} - - - io.netty - netty-handler - ${netty.version} - - - io.netty - netty-handler-proxy - ${netty.version} - - - io.netty - netty-resolver - ${netty.version} - - - io.netty - netty-resolver-dns - ${netty.version} - - - io.netty - netty-resolver-dns-classes-macos - ${netty.version} - io.netty netty-resolver-dns-native-macos - ${netty.version} - - - io.netty - netty-resolver-dns-native-macos - ${netty.version} osx-x86_64 - - io.netty - netty-transport - ${netty.version} - - - io.netty - netty-transport-classes-epoll - ${netty.version} - - - io.netty - netty-transport-classes-kqueue - ${netty.version} - - - io.netty - netty-transport-native-epoll - ${netty.version} - - - io.netty - netty-transport-native-epoll - ${netty.version} - linux-x86_64 - - - io.netty - netty-transport-native-kqueue - ${netty.version} - io.netty netty-transport-native-kqueue - ${netty.version} osx-x86_64 - - io.netty - netty-transport-native-unix-common - ${netty.version} - com.datastax.oss java-driver-core @@ -1523,11 +1230,6 @@ commons-io ${commons-io.version} - - commons-codec - commons-codec - ${commons-codec.version} - commons-logging commons-logging @@ -1538,16 +1240,6 @@ commons-csv ${commons-csv.version} - - org.apache.httpcomponents.client5 - httpclient5 - ${apache-httpclient5.version} - - - org.apache.httpcomponents.core5 - httpcore5 - ${apache-httpcore5.version} - org.apache.httpcomponents httpclient @@ -1559,66 +1251,11 @@ - - org.apache.httpcomponents - httpcore - ${apache-httpcore.version} - joda-time joda-time ${joda-time.version} - - com.fasterxml.jackson.core - jackson-databind - ${jackson-databind.version} - - - com.fasterxml.jackson.core - jackson-core - ${jackson.version} - - - com.fasterxml.jackson.core - jackson-annotations - ${jackson.version} - - - com.fasterxml.jackson.dataformat - jackson-dataformat-cbor - ${jackson.version} - - - com.fasterxml.jackson.dataformat - jackson-dataformat-yaml - ${jackson.version} - - - com.fasterxml.jackson.datatype - jackson-datatype-jdk8 - ${jackson.version} - - - com.fasterxml.jackson.datatype - jackson-datatype-joda - ${jackson.version} - - - com.fasterxml.jackson.datatype - jackson-datatype-jsr310 - ${jackson.version} - - - com.fasterxml.jackson.module - jackson-module-parameter-names - ${jackson.version} - - - com.fasterxml - classmate - ${fasterxml-classmate.version} - com.auth0 java-jwt @@ -1673,61 +1310,11 @@ scandium ${californium.version} - - com.google.code.gson - gson - ${gson.version} - - - org.slf4j - slf4j-api - ${slf4j.version} - - - org.slf4j - log4j-over-slf4j - ${slf4j.version} - - - org.slf4j - jul-to-slf4j - ${slf4j.version} - - - org.apache.logging.log4j - log4j-api - ${log4j.version} - - - org.apache.logging.log4j - log4j-core - ${log4j.version} - - - org.apache.logging.log4j - log4j-to-slf4j - ${log4j.version} - - - ch.qos.logback - logback-core - ${logback.version} - - - ch.qos.logback - logback-classic - ${logback.version} - com.google.guava guava ${guava.version} - - com.github.ben-manes.caffeine - caffeine - ${caffeine.version} - com.google.protobuf protobuf-java @@ -1823,12 +1410,6 @@ tbel ${tbel.version} - - org.springframework - spring-test - ${spring.version} - test - io.takari.junit takari-cpsuite @@ -1846,42 +1427,12 @@ cassandra-all ${cassandra-all.version} - - org.junit.vintage - junit-vintage-engine - ${jupiter.version} - test - - - org.hamcrest - hamcrest-core - - - org.testng testng ${testng.version} test - - org.assertj - assertj-core - ${assertj.version} - test - - - io.rest-assured - rest-assured - ${rest-assured.version} - test - - - org.seleniumhq.selenium - selenium-java - ${selenium.version} - test - io.github.bonigarcia webdrivermanager @@ -1900,18 +1451,6 @@ ${allure-maven.version} test - - org.hamcrest - hamcrest - ${hamcrest.version} - test - - - org.awaitility - awaitility - ${awaitility.version} - test - org.dbunit dbunit @@ -1969,40 +1508,6 @@ bcprov-ext-jdk18on ${bouncycastle.version} - - org.testcontainers - cassandra - ${testcontainers.version} - test - - - org.testcontainers - postgresql - ${testcontainers.version} - test - - - org.testcontainers - jdbc - ${testcontainers.version} - test - - - org.testcontainers - hivemq - ${testcontainers.version} - test - - - org.springframework.data - spring-data-redis - ${spring-data-redis.version} - - - org.springframework.integration - spring-integration-redis - ${spring-redis.version} - redis.clients jedis @@ -2016,17 +1521,6 @@ exe provided - - org.elasticsearch.client - elasticsearch-rest-client - ${elasticsearch.version} - - - commons-logging - commons-logging - - - org.javadelight delight-nashorn-sandbox @@ -2079,10 +1573,55 @@ google-cloud-pubsub ${pubsub.client.version} + + com.google.auth + google-auth-library-credentials + ${google-auth-library.version} + + + com.google.auth + google-auth-library-oauth2-http + ${google-auth-library.version} + + + com.google.http-client + google-http-client + ${google-http-client.version} + + + com.google.http-client + google-http-client-gson + ${google-http-client.version} + + + com.google.api + api-common + ${google-api-common.version} + + + com.google.api + gax + ${google-api-gax.version} + + + com.google.api + gax-grpc + ${google-api-gax.version} + + + com.google.api + gax-httpjson + ${google-api-gax.version} + com.google.api.grpc proto-google-common-protos - ${google.common.protos.version} + ${google-proto-common.version} + + + com.google.api.grpc + proto-google-iam-v1 + ${google-proto-iam-v1.version} org.passay @@ -2110,21 +1649,6 @@ ${java-websocket.version} test - - org.springframework.boot - spring-boot-starter-actuator - ${spring-boot.version} - - - io.micrometer - micrometer-core - ${micrometer.version} - - - io.micrometer - micrometer-registry-prometheus - ${micrometer.version} - org.thingsboard protobuf-dynamic @@ -2154,11 +1678,6 @@ - - org.hibernate.validator - hibernate-validator - ${hibernate-validator.version} - io.hypersistence hypersistence-utils-hibernate-63 @@ -2169,11 +1688,6 @@ jakarta.el ${jakarta.el.version} - - jakarta.validation - jakarta.validation-api - ${jakarta.validation-api.version} - org.owasp.antisamy antisamy @@ -2248,6 +1762,11 @@ + + com.nimbusds + nimbus-jose-jwt + ${nimbus-jose-jwt.version} + org.mock-server mockserver-client-java @@ -2336,6 +1855,36 @@ rocksdbjni ${rocksdbjni.version} + + com.google.errorprone + error_prone_annotations + ${error_prone_annotations.version} + + + org.codehaus.mojo + animal-sniffer-annotations + ${animal-sniffer-annotations.version} + + + com.google.auto.value + auto-value-annotations + ${auto-value-annotations.version} + + + org.checkerframework + checker-qual + ${checker-qual.version} + + + io.perfmark + perfmark-api + ${perfmark-api.version} + + + org.threeten + threetenbp + ${threetenbp.version} + diff --git a/rest-client/pom.xml b/rest-client/pom.xml index a7ed36b546..07499f1129 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 4213d70558..120d27ac89 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 6c1aead1de..a6ee3492e5 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC rule-engine org.thingsboard.rule-engine @@ -98,6 +98,10 @@ jakarta.mail provided
+ + dev.langchain4j + langchain4j + org.springframework.boot spring-boot-starter-test diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java new file mode 100644 index 0000000000..e76cb6b3b2 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAiChatModelService.java @@ -0,0 +1,27 @@ +/** + * 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.rule.engine.api; + +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.response.ChatResponse; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; + +public interface RuleEngineAiChatModelService { + + > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest); + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java index 48fda3b781..edcbffcfbc 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineAlarmService.java @@ -16,6 +16,7 @@ package org.thingsboard.rule.engine.api; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntitySubtype; @@ -41,9 +42,6 @@ import org.thingsboard.server.common.data.query.AlarmDataQuery; import java.util.Collection; -/** - * Created by ashvayka on 02.04.18. - */ public interface RuleEngineAlarmService { /* @@ -78,6 +76,8 @@ public interface RuleEngineAlarmService { Alarm findLatestActiveByOriginatorAndType(TenantId tenantId, EntityId originator, String type); + FluentFuture findLatestActiveByOriginatorAndTypeAsync(TenantId tenantId, EntityId originator, String type); + Alarm findLatestByOriginatorAndType(TenantId tenantId, EntityId originator, String type); AlarmInfo findAlarmInfoById(TenantId tenantId, AlarmId alarmId); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index 16f2936964..d2687a1b10 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.rule.RuleNodeState; import org.thingsboard.server.common.data.script.ScriptLanguage; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -422,6 +423,10 @@ public interface TbContext { AuditLogService getAuditLogService(); + RuleEngineAiChatModelService getAiChatModelService(); + + AiModelService getAiModelService(); + // Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node MqttClientSettings getMqttClientSettings(); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 78ddba15d1..ae8faecb0b 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -16,11 +16,11 @@ package org.thingsboard.rule.engine.api.util; import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.util.CollectionUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -29,15 +29,18 @@ import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -/** - * Created by ashvayka on 19.01.18. - */ -public class TbNodeUtils { +public final class TbNodeUtils { + + private TbNodeUtils() { + throw new IllegalStateException("Utility class"); + } private static final Pattern DATA_PATTERN = Pattern.compile("(\\$\\[)(.*?)(])"); + private static final String ALL_DATA_TEMPLATE = "$[*]"; + private static final String ALL_METADATA_TEMPLATE = "${*}"; + public static T convert(TbNodeConfiguration configuration, Class clazz) throws TbNodeException { try { return JacksonUtil.treeToValue(configuration.getData(), clazz); @@ -47,16 +50,19 @@ public class TbNodeUtils { } public static List processPatterns(List patterns, TbMsg tbMsg) { - if (!CollectionUtils.isEmpty(patterns)) { - return patterns.stream().map(p -> processPattern(p, tbMsg)).collect(Collectors.toList()); + if (CollectionsUtil.isEmpty(patterns)) { + return Collections.emptyList(); } - return Collections.emptyList(); + return patterns.stream().map(p -> processPattern(p, tbMsg)).toList(); } public static String processPattern(String pattern, TbMsg tbMsg) { try { String result = processPattern(pattern, tbMsg.getMetaData()); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); + + result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); + if (json.isObject()) { Matcher matcher = DATA_PATTERN.matcher(result); while (matcher.find()) { @@ -64,7 +70,7 @@ public class TbNodeUtils { String[] keys = group.split("\\."); JsonNode jsonNode = json; for (String key : keys) { - if (!StringUtils.isEmpty(key) && jsonNode != null) { + if (StringUtils.isNotEmpty(key) && jsonNode != null) { jsonNode = jsonNode.get(key); } else { jsonNode = null; @@ -83,15 +89,9 @@ public class TbNodeUtils { } } - @Deprecated(since = "3.6.1", forRemoval = true) - public static List processPatterns(List patterns, TbMsgMetaData metaData) { - if (!CollectionUtils.isEmpty(patterns)) { - return patterns.stream().map(p -> processPattern(p, metaData)).collect(Collectors.toList()); - } - return Collections.emptyList(); - } - - public static String processPattern(String pattern, TbMsgMetaData metaData) { + private static String processPattern(String pattern, TbMsgMetaData metaData) { + String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData()); + pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); return processTemplate(pattern, metaData.values()); } @@ -108,10 +108,11 @@ public class TbNodeUtils { } static String formatDataVarTemplate(String key) { - return "$[" + key + ']'; + return "$[" + key + "]"; } static String formatMetadataVarTemplate(String key) { - return "${" + key + '}'; + return "${" + key + "}"; } + } diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java index 7651a46b62..89d775f305 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java @@ -26,6 +26,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import java.util.Map; + import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -167,4 +169,160 @@ public class TbNodeUtilsTest { assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is("${null}")); assertThat(TbNodeUtils.formatMetadataVarTemplate(null), is(String.format(METADATA_VARIABLE_TEMPLATE, (String) null))); } + + @Test + public void testAllMetadataTemplateReplacement() { + // GIVEN + String pattern = "META ${*}"; + var metadata = new TbMsgMetaData(); + metadata.putValue("meta_key", "meta_value"); + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {\"meta_key\":\"meta_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testMultipleAllMetadataTemplatesReplacement() { + // GIVEN + String pattern = "${*} then again ${*}"; + var metadata = new TbMsgMetaData(); + metadata.putValue("meta_key", "meta_value"); + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "{\"meta_key\":\"meta_value\"} then again {\"meta_key\":\"meta_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataTemplateReplacement() { + // GIVEN + String pattern = "DATA $[*]"; + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "DATA {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testMultipleAllDataTemplatesReplacement() { + // GIVEN + String pattern = "$[*] then again $[*]"; + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "{\"data_key\":\"data_value\"} then again {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataAndAllMetadataTemplatesSimultaneously() { + // GIVEN + String pattern = "META ${*} DATA $[*]"; + + var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value")); + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {\"meta_key\":\"meta_value\"} DATA {\"data_key\":\"data_value\"}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataAndAllMetadataTemplatesSimultaneouslyEmpty() { + // GIVEN + String pattern = "META ${*} DATA $[*]"; + + var msg = TbMsg.newMsg() + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "META {} DATA {}"; + assertThat(actual, is(expected)); + } + + @Test + public void testAllDataTemplateArray() { + // GIVEN + String pattern = "DATA $[*]"; + + var msg = TbMsg.newMsg() + .data("[1, \"two\", true]") + .metaData(TbMsgMetaData.EMPTY) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "DATA [1,\"two\",true]"; + assertThat(actual, is(expected)); + } + + @Test + public void testMixedAllDataMetadataAndNormalTemplates() { + // GIVEN + String pattern = "fullMeta=${*}, singleMeta=${meta_key}, fullData=$[*], singleData=$[data_key]"; + var metadata = new TbMsgMetaData(Map.of("meta_key", "meta_value")); + var dataJson = JacksonUtil.newObjectNode().put("data_key", "data_value"); + + var msg = TbMsg.newMsg() + .data(JacksonUtil.toString(dataJson)) + .metaData(metadata) + .build(); + + // WHEN + String actual = TbNodeUtils.processPattern(pattern, msg); + + // THEN + String expected = "fullMeta={\"meta_key\":\"meta_value\"}, singleMeta=meta_value, fullData={\"data_key\":\"data_value\"}, singleData=data_value"; + assertThat(actual, is(expected)); + } + } diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index b4e74885d9..3f2ee164df 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC rule-engine org.thingsboard.rule-engine @@ -153,6 +153,10 @@ com.jayway.jsonpath json-path + + dev.langchain4j + langchain4j + diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java index 7e3c08cfcc..2d74929242 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -44,7 +44,6 @@ import org.thingsboard.server.common.msg.TbMsg; import java.util.ArrayList; import java.util.List; -import java.util.Set; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT; @@ -115,14 +114,13 @@ public class TbCopyAttributesToEntityViewNode implements TbNode { .build()); } } else { - Set attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); - List filteredAttributes = - attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).collect(Collectors.toList()); + List attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())).stream() + .filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).toList(); ctx.getTelemetryService().saveAttributes(AttributesSaveRequest.builder() .tenantId(ctx.getTenantId()) .entityId(entityView.getId()) .scope(scope) - .entries(filteredAttributes) + .entries(attributes) .callback(getFutureCallback(ctx, msg, entityView)) .build()); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java index 78411d7cec..bd576be936 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCreateAlarmNode.java @@ -70,7 +70,6 @@ public class TbCreateAlarmNode extends TbAbstractAlarmNode { + if (existingAlarm == null || existingAlarm.getStatus().isCleared()) { + return createNewAlarm(ctx, msg, msgAlarm); + } else { + return updateAlarm(ctx, msg, existingAlarm, msgAlarm); + } + }, ctx.getDbCallbackExecutor()); } private Alarm getAlarmFromMessage(TbContext ctx, TbMsg msg) throws IOException { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java new file mode 100644 index 0000000000..b04d4592a2 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/Langchain4jJsonSchemaAdapter.java @@ -0,0 +1,136 @@ +/** + * 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.rule.engine.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.langchain4j.model.chat.request.json.JsonArraySchema; +import dev.langchain4j.model.chat.request.json.JsonBooleanSchema; +import dev.langchain4j.model.chat.request.json.JsonEnumSchema; +import dev.langchain4j.model.chat.request.json.JsonIntegerSchema; +import dev.langchain4j.model.chat.request.json.JsonNullSchema; +import dev.langchain4j.model.chat.request.json.JsonNumberSchema; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.request.json.JsonSchemaElement; +import dev.langchain4j.model.chat.request.json.JsonStringSchema; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts a Jackson {@link ObjectNode} JSON Schema into a Langchain4j {@link JsonSchema} model. + */ +final class Langchain4jJsonSchemaAdapter { + + private Langchain4jJsonSchemaAdapter() { + throw new AssertionError("Can't instantiate utility class"); + } + + /** + * Creates a Langchain4j {@link JsonSchema} from the given root JSON Schema node. + * + * @param rootSchemaNode a valid JSON Schema as a Jackson {@link ObjectNode} + * @return the corresponding Langchain4j {@link JsonSchema} + */ + public static JsonSchema fromObjectNode(ObjectNode rootSchemaNode) { + return JsonSchema.builder() + .name(rootSchemaNode.get("title").textValue()) + .rootElement(parse(rootSchemaNode)) + .build(); + } + + private static JsonSchemaElement parse(JsonNode schemaNode) { + String description = schemaNode.hasNonNull("description") ? schemaNode.get("description").textValue() : null; + + if (schemaNode.has("enum")) { // enum schemas can be defined without 'type' + return parseEnum(schemaNode).description(description).build(); + } + + String type = schemaNode.get("type").textValue(); + + return switch (type) { + case "string" -> JsonStringSchema.builder().description(description).build(); + case "integer" -> JsonIntegerSchema.builder().description(description).build(); + case "boolean" -> JsonBooleanSchema.builder().description(description).build(); + case "number" -> JsonNumberSchema.builder().description(description).build(); + case "null" -> new JsonNullSchema(); + case "object" -> parseObject(schemaNode).description(description).build(); + case "array" -> parseArray(schemaNode).description(description).build(); + default -> throw new IllegalArgumentException("Unsupported JSON Schema type: " + type); + }; + } + + private static JsonEnumSchema.Builder parseEnum(JsonNode enumSchema) { + var builder = new JsonEnumSchema.Builder(); + + List enumValues = new ArrayList<>(); + for (JsonNode element : enumSchema.get("enum")) { + if (!element.isTextual()) { + throw new IllegalArgumentException("Expected each 'enum' element to be a string, but found: " + element.getNodeType()); + } + enumValues.add(element.textValue()); + } + builder.enumValues(enumValues); + + return builder; + } + + private static JsonObjectSchema.Builder parseObject(JsonNode objectSchema) { + var builder = new JsonObjectSchema.Builder(); + + JsonNode propertiesNode = objectSchema.get("properties"); + if (propertiesNode != null) { + propertiesNode.fields().forEachRemaining(entry -> { + String key = entry.getKey(); + JsonNode value = entry.getValue(); + builder.addProperty(key, parse(value)); + }); + } + + List required = new ArrayList<>(); + JsonNode requiredNode = objectSchema.get("required"); + if (requiredNode != null) { + for (JsonNode value : requiredNode) { + required.add(value.textValue()); + } + } + builder.required(required); + + boolean additionalProperties = true; // default value if 'additionalProperties' is not set + JsonNode additionalPropertiesNode = objectSchema.get("additionalProperties"); + if (additionalPropertiesNode != null) { + if (!additionalPropertiesNode.isBoolean()) { + throw new IllegalArgumentException("Expected 'additionalProperties' to be a boolean, but found: " + additionalPropertiesNode.getNodeType()); + } + additionalProperties = additionalPropertiesNode.booleanValue(); + } + builder.additionalProperties(additionalProperties); + + return builder; + } + + private static JsonArraySchema.Builder parseArray(JsonNode arraySchema) { + var builder = new JsonArraySchema.Builder(); + + if (arraySchema.hasNonNull("items")) { + builder.items(parse(arraySchema.get("items"))); + } + + return builder; + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java new file mode 100644 index 0000000000..3497795771 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java @@ -0,0 +1,204 @@ +/** + * 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.rule.engine.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.FutureCallback; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ChatRequest; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.response.ChatResponse; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.RuleNode; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.rule.engine.external.TbAbstractExternalNode; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelType; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +import static com.google.common.util.concurrent.MoreExecutors.directExecutor; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbResponseFormatType; +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; + +@RuleNode( + type = ComponentType.EXTERNAL, + name = "AI request", + nodeDescription = "Sends a request to an AI model using system and user prompts. Supports JSON mode.", + nodeDetails = """ + Interact with large language models (LLMs) by sending dynamic requests from your rule chain. + You can select a specific AI model and define its behavior using a system prompt (optional context or role) and a user prompt (the main task). + Both prompts can be populated with data and metadata from the incoming message using patterns. + For example, the $[*] and ${*} patterns allow you to access the all message body and all metadata, respectively. +

+ After sending the request, the node waits for a response within a configured timeout. + You can specify the desired response format as Text, JSON, or provide a specific JSON Schema to structure the output. + The AI-generated content is forwarded as the body of the outgoing message; the originator, message type, and metadata from the incoming message remain unchanged. +

+ Output connections: Success, Failure. + """, + configClazz = TbAiNodeConfiguration.class, + configDirective = "tbExternalNodeAiConfig", + iconUrl = "data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDkiIGhlaWdodD0iNDgiIHZpZXdCb3g9IjAgMCA0OSA0OCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0zOC42MzExIDE3LjA3OTVDNDAuMTcwNSAxNy4wNzk2IDQxLjY1MTggMTcuNjg3MiA0Mi43NDc4IDE4Ljc3NjNDNDMuODQ0OCAxOS44NjYzIDQ0LjQ2NTkgMjEuMzUwMSA0NC40NjU5IDIyLjkwMjlWMzUuNDY1MkM0NC40NjU5IDM2LjM1MDkgNDQuMzU2NyAzNy4wNzY5IDQ0LjA5NzMgMzcuNzUxN0M0My44NDE0IDM4LjQxNjcgNDMuNDY1MSAzOC45NjE0IDQzLjA0NDggMzkuNTAyOEM0Mi40NjY3IDQwLjI0NzIgNDEuNjU2MyA0MC42ODU5IDQwLjg5MTkgNDAuOTM4OEM0MC4xMjExIDQxLjE5MzcgMzkuMzE0MyA0MS4yODg1IDM4LjYzMTEgNDEuMjg4NUgzMS4wMjU5TDIzLjM4MTIgNDUuODQ2NEMyMy4wNDMxIDQ2LjA0NzggMjIuNjI0MSA0Ni4wNTA3IDIyLjI4MzkgNDUuODUyOUMyMS45NDM3IDQ1LjY1NDcgMjEuNzMzOCA0NS4yODU5IDIxLjczMzcgNDQuODg3MlY0MS4yODg1SDE5LjY2NjNDMTguMTI2OSA0MS4yODg0IDE2LjY0NTUgNDAuNjgwOSAxNS41NDk2IDM5LjU5MThDMTQuNDUyNyAzOC41MDE5IDEzLjgzMTUgMzcuMDE3OSAxMy44MzE1IDM1LjQ2NTJWMjIuOTAyOUMxMy44MzE1IDIyLjMyMDIgMTMuOTE4NSAyMS43NDY4IDE0LjA4NTggMjEuMjAwN0wxNi4yODg5IDIxLjgxMDFMMTcuMjA5OSAyNS4yNTAyQzE3Ljk0MTYgMjcuOTg0NSAyMS43NTYyIDI3Ljk4NDQgMjIuNDg4IDI1LjI1MDJMMjMuNDA3OSAyMS44MTAxTDI2Ljc5MTcgMjAuODc0OUMyOC41NzkxIDIwLjM4MDUgMjkuMTc3IDE4LjUwMjYgMjguNTg4OCAxNy4wNzk1SDM4LjYzMTFaTTIyLjU4NDIgMzEuNTM5NUMyMS45OCAzMS41Mzk3IDIxLjQ5MDEgMzIuMDM3NiAyMS40OTAxIDMyLjY1MTlDMjEuNDkwMiAzMy4yNjYgMjEuOTgwMSAzMy43NjQgMjIuNTg0MiAzMy43NjQySDM0LjYxOTFDMzUuMjIzMyAzMy43NjQyIDM1LjcxMzEgMzMuMjY2MSAzNS43MTMyIDMyLjY1MTlDMzUuNzEzMiAzMi4wMzc1IDM1LjIyMzQgMzEuNTM5NSAzNC42MTkxIDMxLjUzOTVIMjIuNTg0MlpNMjQuNzcyMyAyNC44NjU3QzI0LjE2ODIgMjQuODY1OCAyMy42NzgzIDI1LjM2MzggMjMuNjc4MyAyNS45NzhDMjMuNjc4NCAyNi41OTIyIDI0LjE2ODMgMjcuMDkwMiAyNC43NzIzIDI3LjA5MDNIMzcuOTAxNEMzOC41MDU1IDI3LjA5MDMgMzguOTk1MyAyNi41OTIyIDM4Ljk5NTQgMjUuOTc4QzM4Ljk5NTQgMjUuMzYzNyAzOC41MDU2IDI0Ljg2NTcgMzcuOTAxNCAyNC44NjU3SDI0Ljc3MjNaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xOC43ODkxIDExLjI5NzVDMTkuMDY5MSAxMC4xODA4IDIwLjYyOTkgMTAuMTgwOCAyMC45MDk5IDExLjI5NzVMMjEuOTE0MyAxNS4zMDM2QzIyLjAxMTYgMTUuNjkxOCAyMi4zMDY1IDE1Ljk5NzggMjIuNjg2NyAxNi4xMDNMMjYuMzYxMSAxNy4xMTg3QzI3LjQzNyAxNy40MTYyIDI3LjQzNyAxOC45Njc2IDI2LjM2MTEgMTkuMjY1MUwyMi42NzYxIDIwLjI4NEMyMi4zMDE4IDIwLjM4NzQgMjIuMDA4NyAyMC42ODQ1IDIxLjkwNjggMjEuMDY1TDIwLjkwNDYgMjQuODEyNUMyMC42MTE3IDI1LjkwNTggMTkuMDg2MSAyNS45MDU5IDE4Ljc5MzMgMjQuODEyNUwxNy43OTExIDIxLjA2NUMxNy42ODkzIDIwLjY4NDcgMTcuMzk3IDIwLjM4NzUgMTcuMDIyOSAyMC4yODRMMTMuMzM2OCAxOS4yNjUxQzEyLjI2MTQgMTguOTY3MyAxMi4yNjE1IDE3LjQxNjUgMTMuMzM2OCAxNy4xMTg3TDE3LjAxMTIgMTYuMTAzQzE3LjM5MTYgMTUuOTk3OCAxNy42ODc0IDE1LjY5MTkgMTcuNzg0NyAxNS4zMDM2TDE4Ljc4OTEgMTEuMjk3NVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTEwLjAzNDMgNy4wMjQyNUMxMC4zMDY4IDUuODk0NDQgMTEuODg2OCA1Ljg5NDQ0IDEyLjE1OTQgNy4wMjQyNUwxMi42OTg5IDkuMjYyOThDMTIuNzkyNyA5LjY1MTc0IDEzLjA4NTEgOS45NTg4NyAxMy40NjQgMTAuMDY3OUwxNS41NzczIDEwLjY3NTFDMTYuNjM5MyAxMC45ODAzIDE2LjYzOTMgMTIuNTEwOSAxNS41NzczIDEyLjgxNjFMMTMuNDUzMyAxMy40MjY1QzEzLjA4MDIgMTMuNTMzOCAxMi43OTA4IDEzLjgzMzkgMTIuNjkyNSAxNC4yMTUxTDEyLjE1NTEgMTYuMzA0QzExLjg3IDE3LjQxMTYgMTAuMzIzNiAxNy40MTE2IDEwLjAzODUgMTYuMzA0TDkuNTAwMDMgMTQuMjE1MUM5LjQwMTczIDEzLjgzMzkgOS4xMTIzNSAxMy41MzM3IDguNzM5MyAxMy40MjY1TDYuNjE1MjQgMTIuODE2MUM1LjU1Mzc4IDEyLjUxMDYgNS41NTM2NCAxMC45ODA0IDYuNjE1MjQgMTAuNjc1MUw4LjcyODYyIDEwLjA2NzlDOS4xMDc2IDkuOTU4OTggOS4zOTk3OCA5LjY1MTg0IDkuNDkzNjIgOS4yNjI5OEwxMC4wMzQzIDcuMDI0MjVaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0yNS45MDI4IDYuNzMzMTNDMjYuMTg3OCA1LjYyNTQxIDI3LjczNDMgNS42MjU0MSAyOC4wMTkzIDYuNzMzMTNMMjguMjAzMSA3LjQ0Njc5QzI4LjMwMyA3LjgzNDMxIDI4LjYwMDEgOC4xMzcwNSAyOC45ODA5IDguMjM5NzVMMjkuNTM0NCA4LjM4OTY1QzMwLjYxOTIgOC42ODIxMiAzMC42MTkzIDEwLjI0NjkgMjkuNTM0NCAxMC41MzkzTDI4Ljk2OTIgMTAuNjkxNEMyOC41OTQ0IDEwLjc5MjUgMjguMjk5OSAxMS4wODgzIDI4LjE5NTYgMTEuNDY4TDI4LjAxNTEgMTIuMTI4NUMyNy43MTc0IDEzLjIxMjggMjYuMjA0NyAxMy4yMTI4IDI1LjkwNyAxMi4xMjg1TDI1LjcyNTQgMTEuNDY4QzI1LjYyMTEgMTEuMDg4MiAyNS4zMjY4IDEwLjc5MjQgMjQuOTUxOCAxMC42OTE0TDI0LjM4NzcgMTAuNTM5M0MyMy4zMDI2IDEwLjI0NyAyMy4zMDI2IDguNjgxOTggMjQuMzg3NyA4LjM4OTY1TDI0Ljk0MDEgOC4yMzk3NUMyNS4zMjExIDguMTM3MDkgMjUuNjE5MSA3LjgzNDQ2IDI1LjcxOSA3LjQ0Njc5TDI1LjkwMjggNi43MzMxM1oiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==", + docUrl = "https://thingsboard.io/docs/user-guide/rule-engine-2-0/external-nodes/#ai-request-node", + ruleChainTypes = RuleChainType.CORE +) +public final class TbAiNode extends TbAbstractExternalNode implements TbNode { + + private String systemPrompt; + private String userPrompt; + private ResponseFormat responseFormat; + private int timeoutSeconds; + private AiModelId modelId; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + super.init(ctx); + + var config = TbNodeUtils.convert(configuration, TbAiNodeConfiguration.class); + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + + modelId = config.getModelId(); + Optional modelOpt = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId); + if (modelOpt.isEmpty()) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found", true); + } + AiModel model = modelOpt.get(); + AiModelType modelType = model.getConfiguration().modelType(); + if (modelType != AiModelType.CHAT) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType, true); + } + AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); + if (isJsonModeConfigured(config)) { + if (!chatModelConfig.supportsJsonMode()) { + throw new TbNodeException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] does not support '" + config.getResponseFormat().type() + "' response format", true); + } + // LangChain4j AnthropicChatModel rejects requests with non-null ResponseFormat even if ResponseFormatType is TEXT + responseFormat = config.getResponseFormat().toLangChainResponseFormat(); + } + + systemPrompt = config.getSystemPrompt(); + userPrompt = config.getUserPrompt(); + timeoutSeconds = config.getTimeoutSeconds(); + super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so + } + + private static boolean isJsonModeConfigured(TbAiNodeConfiguration config) { + var responseFormatType = config.getResponseFormat().type(); + return responseFormatType == TbResponseFormatType.JSON || responseFormatType == TbResponseFormatType.JSON_SCHEMA; + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) { + var ackedMsg = ackIfNeeded(ctx, msg); + + List chatMessages = new ArrayList<>(2); + if (systemPrompt != null) { + chatMessages.add(SystemMessage.from(TbNodeUtils.processPattern(systemPrompt, ackedMsg))); + } + chatMessages.add(UserMessage.from(TbNodeUtils.processPattern(userPrompt, ackedMsg))); + + var chatRequest = ChatRequest.builder() + .messages(chatMessages) + .responseFormat(responseFormat) + .build(); + + sendChatRequestAsync(ctx, chatRequest).addCallback(new FutureCallback<>() { + @Override + public void onSuccess(ChatResponse chatResponse) { + String response = chatResponse.aiMessage().text(); + if (!isValidJsonObject(response)) { + response = wrapInJsonObject(response); + } + tellSuccess(ctx, ackedMsg.transform() + .data(response) + .build()); + } + + @Override + public void onFailure(@NonNull Throwable t) { + tellFailure(ctx, ackedMsg, t); + } + }, directExecutor()); + } + + private > FluentFuture sendChatRequestAsync(TbContext ctx, ChatRequest chatRequest) { + return ctx.getAiModelService().findAiModelByTenantIdAndIdAsync(ctx.getTenantId(), modelId).transformAsync(modelOpt -> { + if (modelOpt.isEmpty()) { + throw new NoSuchElementException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] was not found"); + } + AiModel model = modelOpt.get(); + AiModelType modelType = model.getConfiguration().modelType(); + if (modelType != AiModelType.CHAT) { + throw new IllegalStateException("[" + ctx.getTenantId() + "] AI model with ID: [" + modelId + "] must be of type CHAT, but was " + modelType); + } + + @SuppressWarnings("unchecked") + AiChatModelConfig chatModelConfig = (AiChatModelConfig) model.getConfiguration(); + + chatModelConfig = chatModelConfig + .withTimeoutSeconds(timeoutSeconds) + .withMaxRetries(0); // disable retries to respect timeout set in rule node config + + return ctx.getAiChatModelService().sendChatRequestAsync(chatModelConfig, chatRequest); + }, ctx.getDbCallbackExecutor()); + } + + private static boolean isValidJsonObject(String jsonString) { + try { + JsonNode result = JacksonUtil.toJsonNode(jsonString); + return result != null && result.isObject(); + } catch (IllegalArgumentException e) { + return false; + } + } + + private static String wrapInJsonObject(String response) { + return JacksonUtil.newObjectNode().put("response", response).toString(); + } + + @Override + public void destroy() { + super.destroy(); + systemPrompt = null; + userPrompt = null; + responseFormat = null; + modelId = null; + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java new file mode 100644 index 0000000000..10bb24199e --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.thingsboard.rule.engine.api.NodeConfiguration; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.validation.Length; + +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; + +@Data +public class TbAiNodeConfiguration implements NodeConfiguration { + + @NotNull + private AiModelId modelId; + + @Pattern(regexp = ".*\\S.*", message = "must not be blank") + @Length(min = 1, max = 10000) + private String systemPrompt; + + @NotBlank + @Length(min = 1, max = 10000) + private String userPrompt; + + @NotNull + @Valid + private TbResponseFormat responseFormat; + + @Min(value = 1, message = "must be at least 1 second") + @Max(value = 600, message = "cannot exceed 600 seconds (10 minutes)") + private int timeoutSeconds; + + private boolean forceAck; + + @Override + public TbAiNodeConfiguration defaultConfiguration() { + var configuration = new TbAiNodeConfiguration(); + configuration.setSystemPrompt( + "You are a helpful AI assistant. Your primary function is to process the user's request and respond with a valid JSON object. " + + "Do not include any text, explanations, or markdown formatting before or after the JSON output." + ); + configuration.setResponseFormat(new TbJsonResponseFormat()); + configuration.setTimeoutSeconds(60); + configuration.setForceAck(true); + return configuration; + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java new file mode 100644 index 0000000000..5c891a9c74 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbResponseFormat.java @@ -0,0 +1,103 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.ai; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.node.ObjectNode; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import jakarta.validation.constraints.NotNull; +import org.thingsboard.server.common.data.validation.ValidJsonSchema; + +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; +import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = TbTextResponseFormat.class, name = "TEXT"), + @JsonSubTypes.Type(value = TbJsonResponseFormat.class, name = "JSON"), + @JsonSubTypes.Type(value = TbJsonSchemaResponseFormat.class, name = "JSON_SCHEMA") +}) +public sealed interface TbResponseFormat permits TbTextResponseFormat, TbJsonResponseFormat, TbJsonSchemaResponseFormat { + + TbResponseFormatType type(); + + ResponseFormat toLangChainResponseFormat(); + + enum TbResponseFormatType { + + TEXT, + JSON, + JSON_SCHEMA + + } + + record TbTextResponseFormat() implements TbResponseFormat { + + @Override + public TbResponseFormatType type() { + return TbResponseFormatType.TEXT; + } + + @Override + public ResponseFormat toLangChainResponseFormat() { + return ResponseFormat.builder() + .type(ResponseFormatType.TEXT) + .build(); + } + + } + + record TbJsonResponseFormat() implements TbResponseFormat { + + @Override + public TbResponseFormatType type() { + return TbResponseFormatType.JSON; + } + + @Override + public ResponseFormat toLangChainResponseFormat() { + return ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .build(); + } + + } + + record TbJsonSchemaResponseFormat(@NotNull @ValidJsonSchema ObjectNode schema) implements TbResponseFormat { + + @Override + public TbResponseFormatType type() { + return TbResponseFormatType.JSON_SCHEMA; + } + + @Override + public ResponseFormat toLangChainResponseFormat() { + return ResponseFormat.builder() + .type(ResponseFormatType.JSON) + .jsonSchema(Langchain4jJsonSchemaAdapter.fromObjectNode(schema)) + .build(); + } + + } + +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java index 374cfec1bf..42d9bc85e5 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.msg.TbMsg; public abstract class TbAbstractExternalNode implements TbNode { - private boolean forceAck; + protected boolean forceAck; public void init(TbContext ctx) { this.forceAck = ctx.isExternalNodeForceAck(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java index 694fed1bf8..87643ae46d 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java @@ -45,7 +45,6 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import javax.net.ssl.SSLException; -import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -64,12 +63,10 @@ import java.util.concurrent.TimeoutException; ) public class TbMqttNode extends TbAbstractExternalNode { - private static final Charset UTF8 = StandardCharsets.UTF_8; - - private static final String ERROR = "error"; + private static final int MQTT_3_MAX_CLIENT_ID_LENGTH = 23; + private static final int MQTT_5_MAX_CLIENT_ID_LENGTH = 256; protected TbMqttNodeConfiguration mqttNodeConfiguration; - protected MqttClient mqttClient; @Override @@ -87,9 +84,9 @@ public class TbMqttNode extends TbAbstractExternalNode { @Override public void onMsg(TbContext ctx, TbMsg msg) { - String topic = TbNodeUtils.processPattern(this.mqttNodeConfiguration.getTopicPattern(), msg); + String topic = TbNodeUtils.processPattern(mqttNodeConfiguration.getTopicPattern(), msg); var tbMsg = ackIfNeeded(ctx, msg); - this.mqttClient.publish(topic, Unpooled.wrappedBuffer(getData(tbMsg, mqttNodeConfiguration.isParseToPlainText()).getBytes(UTF8)), + this.mqttClient.publish(topic, Unpooled.wrappedBuffer(getData(tbMsg, mqttNodeConfiguration.isParseToPlainText()).getBytes(StandardCharsets.UTF_8)), MqttQoS.AT_LEAST_ONCE, mqttNodeConfiguration.isRetainedMessage()) .addListener(future -> { if (future.isSuccess()) { @@ -103,7 +100,7 @@ public class TbMqttNode extends TbAbstractExternalNode { private TbMsg processException(TbMsg origMsg, Throwable e) { TbMsgMetaData metaData = origMsg.getMetaData().copy(); - metaData.putValue(ERROR, e.getClass() + ": " + e.getMessage()); + metaData.putValue("error", e.getClass() + ": " + e.getMessage()); return origMsg.transform() .metaData(metaData) .build(); @@ -111,8 +108,8 @@ public class TbMqttNode extends TbAbstractExternalNode { @Override public void destroy() { - if (this.mqttClient != null) { - this.mqttClient.disconnect(); + if (mqttClient != null) { + mqttClient.disconnect(); } } @@ -123,11 +120,11 @@ public class TbMqttNode extends TbAbstractExternalNode { protected MqttClient initClient(TbContext ctx) throws Exception { MqttClientConfig config = new MqttClientConfig(getSslContext()); config.setOwnerId(getOwnerId(ctx)); - if (!StringUtils.isEmpty(this.mqttNodeConfiguration.getClientId())) { + if (!StringUtils.isEmpty(mqttNodeConfiguration.getClientId())) { config.setClientId(getClientId(ctx)); } - config.setCleanSession(this.mqttNodeConfiguration.isCleanSession()); - config.setProtocolVersion(this.mqttNodeConfiguration.getProtocolVersion()); + config.setCleanSession(mqttNodeConfiguration.isCleanSession()); + config.setProtocolVersion(mqttNodeConfiguration.getProtocolVersion()); MqttClientSettings mqttClientSettings = ctx.getMqttClientSettings(); config.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig( @@ -139,32 +136,32 @@ public class TbMqttNode extends TbAbstractExternalNode { prepareMqttClientConfig(config); MqttClient client = getMqttClient(ctx, config); client.setEventLoop(ctx.getSharedEventLoop()); - Promise connectFuture = client.connect(this.mqttNodeConfiguration.getHost(), this.mqttNodeConfiguration.getPort()); + Promise connectFuture = client.connect(mqttNodeConfiguration.getHost(), mqttNodeConfiguration.getPort()); MqttConnectResult result; try { - result = connectFuture.get(this.mqttNodeConfiguration.getConnectTimeoutSec(), TimeUnit.SECONDS); + result = connectFuture.get(mqttNodeConfiguration.getConnectTimeoutSec(), TimeUnit.SECONDS); } catch (TimeoutException ex) { connectFuture.cancel(true); client.disconnect(); - String hostPort = this.mqttNodeConfiguration.getHost() + ":" + this.mqttNodeConfiguration.getPort(); + String hostPort = mqttNodeConfiguration.getHost() + ":" + mqttNodeConfiguration.getPort(); throw new RuntimeException(String.format("Failed to connect to MQTT broker at %s.", hostPort)); } if (!result.isSuccess()) { connectFuture.cancel(true); client.disconnect(); - String hostPort = this.mqttNodeConfiguration.getHost() + ":" + this.mqttNodeConfiguration.getPort(); + String hostPort = mqttNodeConfiguration.getHost() + ":" + mqttNodeConfiguration.getPort(); throw new RuntimeException(String.format("Failed to connect to MQTT broker at %s. Result code is: %s", hostPort, result.getReturnCode())); } return client; } private String getClientId(TbContext ctx) throws TbNodeException { - String clientId = this.mqttNodeConfiguration.isAppendClientIdSuffix() ? - this.mqttNodeConfiguration.getClientId() + "_" + ctx.getServiceId() : - this.mqttNodeConfiguration.getClientId(); - if (clientId.length() > 23) { - throw new TbNodeException("Client ID is too long '" + clientId + "'. " + - "The length of Client ID cannot be longer than 23, but current length is " + clientId.length() + ".", true); + String clientId = mqttNodeConfiguration.isAppendClientIdSuffix() ? + mqttNodeConfiguration.getClientId() + "_" + ctx.getServiceId() : + mqttNodeConfiguration.getClientId(); + int maxLength = mqttNodeConfiguration.getProtocolVersion() == MqttVersion.MQTT_3_1 ? MQTT_3_MAX_CLIENT_ID_LENGTH : MQTT_5_MAX_CLIENT_ID_LENGTH; + if (clientId.length() > maxLength) { + throw new TbNodeException("The length of Client ID cannot be longer than " + maxLength + ", but current length is " + clientId.length() + ".", true); } return clientId; } @@ -174,7 +171,7 @@ public class TbMqttNode extends TbAbstractExternalNode { } protected void prepareMqttClientConfig(MqttClientConfig config) { - ClientCredentials credentials = this.mqttNodeConfiguration.getCredentials(); + ClientCredentials credentials = mqttNodeConfiguration.getCredentials(); if (credentials.getType() == CredentialsType.BASIC) { BasicCredentials basicCredentials = (BasicCredentials) credentials; config.setUsername(basicCredentials.getUsername()); @@ -183,7 +180,7 @@ public class TbMqttNode extends TbAbstractExternalNode { } private SslContext getSslContext() throws SSLException { - return this.mqttNodeConfiguration.isSsl() ? this.mqttNodeConfiguration.getCredentials().initSslContext() : null; + return mqttNodeConfiguration.isSsl() ? mqttNodeConfiguration.getCredentials().initSslContext() : null; } private String getData(TbMsg tbMsg, boolean parseToPlainText) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java index 2ea56ce799..26c5b3fa42 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.mqtt.azure; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.annotations.VisibleForTesting; import io.netty.handler.codec.mqtt.MqttVersion; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.AzureIotHubUtil; @@ -36,6 +37,8 @@ import org.thingsboard.server.common.data.plugin.ComponentClusteringMode; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; +import java.time.Clock; + @Slf4j @RuleNode( type = ComponentType.EXTERNAL, @@ -49,6 +52,8 @@ import org.thingsboard.server.common.data.util.TbPair; ) public class TbAzureIotHubNode extends TbMqttNode { + private Clock clock = Clock.systemUTC(); + @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); @@ -73,7 +78,7 @@ public class TbAzureIotHubNode extends TbMqttNode { config.setUsername(AzureIotHubUtil.buildUsername(mqttNodeConfiguration.getHost(), config.getClientId())); ClientCredentials credentials = mqttNodeConfiguration.getCredentials(); if (CredentialsType.SAS == credentials.getType()) { - config.setPassword(AzureIotHubUtil.buildSasToken(mqttNodeConfiguration.getHost(), ((AzureIotHubSasCredentials) credentials).getSasKey())); + config.setPassword(AzureIotHubUtil.buildSasToken(mqttNodeConfiguration.getHost(), ((AzureIotHubSasCredentials) credentials).getSasKey(), clock)); } } @@ -81,6 +86,11 @@ public class TbAzureIotHubNode extends TbMqttNode { return initClient(ctx); } + @VisibleForTesting + void setClock(Clock clock) { + this.clock = clock; + } + @Override public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { boolean hasChanges = false; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java index 193c179df2..4bd81050db 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java @@ -258,7 +258,7 @@ class DeviceState { private boolean processAttributes(TbContext ctx, TbMsg msg, String scope) throws ExecutionException, InterruptedException { boolean stateChanged = false; - Set attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); + List attributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); if (!attributes.isEmpty()) { SnapshotUpdate update = merge(latestValues, attributes, scope); for (DeviceProfileAlarm alarm : deviceProfile.getAlarmSettings()) { @@ -321,7 +321,7 @@ class DeviceState { return new SnapshotUpdate(AlarmConditionKeyType.TIME_SERIES, keys); } - private SnapshotUpdate merge(DataSnapshot latestValues, Set attributes, String scope) { + private SnapshotUpdate merge(DataSnapshot latestValues, List attributes, String scope) { long newTs = 0; Set keys = new HashSet<>(); for (AttributeKvEntry entry : attributes) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java index 4e319500d1..e703e9dd25 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbCalculatedFieldsNode.java @@ -103,8 +103,7 @@ public class TbCalculatedFieldsNode implements TbNode { } private void processPostAttributesRequest(TbContext ctx, TbMsg msg) { - List newAttributes = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData()))); - + List newAttributes = JsonConverter.convertToAttributes(JsonParser.parseString(msg.getData())); if (newAttributes.isEmpty()) { ctx.tellSuccess(msg); return; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index c04f5b474d..280d8de824 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -41,7 +41,6 @@ import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -133,7 +132,7 @@ public class TbMsgAttributesNode implements TbNode { return; } String src = msg.getData(); - List newAttributes = new ArrayList<>(JsonConverter.convertToAttributes(JsonParser.parseString(src))); + List newAttributes = JsonConverter.convertToAttributes(JsonParser.parseString(src)); if (newAttributes.isEmpty()) { ctx.tellSuccess(msg); return; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java index 93fad4c0e7..8ea0f70a3f 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/util/TenantIdLoader.java @@ -19,6 +19,7 @@ import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.id.AiModelId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.AssetId; @@ -146,6 +147,7 @@ public class TenantIdLoader { tenantEntity = ctx.getNotificationRequestService().findNotificationRequestById(ctxTenantId, new NotificationRequestId(id)); break; case NOTIFICATION: + case ADMIN_SETTINGS: return ctxTenantId; case NOTIFICATION_RULE: tenantEntity = ctx.getNotificationRuleService().findNotificationRuleById(ctxTenantId, new NotificationRuleId(id)); @@ -179,6 +181,9 @@ public class TenantIdLoader { case JOB: tenantEntity = ctx.getJobService().findJobById(ctxTenantId, new JobId(id)); break; + case AI_MODEL: + tenantEntity = ctx.getAiModelService().findAiModelById(ctxTenantId, new AiModelId(id)).orElse(null); + break; default: throw new RuntimeException("Unexpected entity type: " + entityId.getEntityType()); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java index 6cb8299b67..24fd912997 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/action/TbCreateAlarmNodeTest.java @@ -17,7 +17,7 @@ package org.thingsboard.rule.engine.action; import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.databind.JsonNode; -import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.FluentFuture; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -60,6 +60,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ExecutionException; +import static com.google.common.util.concurrent.Futures.immediateFailedFuture; +import static com.google.common.util.concurrent.Futures.immediateFuture; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -67,6 +69,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.eq; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @@ -99,6 +102,8 @@ class TbCreateAlarmNodeTest { dbExecutor = new TestDbCallbackExecutor(); metadata = new TbMsgMetaData(); config = new TbCreateAlarmNodeConfiguration(); + + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(dbExecutor); } @Test @@ -212,10 +217,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFuture(alarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(immediateFuture(alarmDetails)); var apiCallResult = AlarmApiCallResult.builder() .successful(true) .created(true) @@ -230,11 +234,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, TbAbstractAlarmNodeConfiguration.ALARM_DETAILS_BUILD_TBEL_TEMPLATE)).willReturn(alarmDetailsScriptMock); @@ -384,10 +388,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingClearedAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFuture(alarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingClearedAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(immediateFuture(alarmDetails)); var apiCallResult = AlarmApiCallResult.builder() .successful(true) .created(true) @@ -402,11 +405,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.JS, config.getAlarmDetailsBuildJs())).willReturn(alarmDetailsScriptMock); @@ -576,10 +579,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(newAlarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingActiveAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(immediateFuture(newAlarmDetails)); doReturn(newEndTs).when(nodeSpy).currentTimeMillis(); var apiCallResult = AlarmApiCallResult.builder() .successful(true) @@ -595,11 +597,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -753,9 +755,8 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingClearedAlarm); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingClearedAlarm))); var apiCallResult = AlarmApiCallResult.builder() .successful(true) .created(true) @@ -770,11 +771,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedCreatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_CREATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -941,10 +942,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(newAlarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingActiveAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(immediateFuture(newAlarmDetails)); doReturn(newEndTs).when(nodeSpy).currentTimeMillis(); var apiCallResult = AlarmApiCallResult.builder() .successful(true) @@ -960,11 +960,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -1125,10 +1125,9 @@ class TbCreateAlarmNodeTest { // mocks given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.getSelfId()).willReturn(ruleNodeSelfId); - given(alarmServiceMock.findLatestActiveByOriginatorAndType(tenantId, msgOriginator, alarmType)).willReturn(existingActiveAlarm); - given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(Futures.immediateFuture(alarmDetails)); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, alarmType)).willReturn(FluentFuture.from(immediateFuture(existingActiveAlarm))); + given(alarmDetailsScriptMock.executeJsonAsync(any())).willReturn(immediateFuture(alarmDetails)); doReturn(endTs).when(nodeSpy).currentTimeMillis(); var apiCallResult = AlarmApiCallResult.builder() .successful(true) @@ -1144,11 +1143,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.alarmActionMsg(expectedUpdatedAlarmInfo, ruleNodeSelfId, TbMsgType.ENTITY_UPDATED)).willReturn(alarmActionMsgMock); given(ctxMock.transformMsg(any(TbMsg.class), any(TbMsgType.class), any(EntityId.class), any(TbMsgMetaData.class), anyString())) .willAnswer(answer -> answer.getArgument(0, TbMsg.class).transform() - .type(answer.getArgument(1, TbMsgType.class)) - .originator(answer.getArgument(2, EntityId.class)) - .metaData(answer.getArgument(3, TbMsgMetaData.class)) - .data(answer.getArgument(4, String.class)) - .build() + .type(answer.getArgument(1, TbMsgType.class)) + .originator(answer.getArgument(2, EntityId.class)) + .metaData(answer.getArgument(3, TbMsgMetaData.class)) + .data(answer.getArgument(4, String.class)) + .build() ); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); @@ -1216,11 +1215,11 @@ class TbCreateAlarmNodeTest { given(ctxMock.getTenantId()).willReturn(tenantId); given(ctxMock.getAlarmService()).willReturn(alarmServiceMock); - given(ctxMock.getDbCallbackExecutor()).willReturn(dbExecutor); given(ctxMock.createScriptEngine(ScriptLanguage.TBEL, config.getAlarmDetailsBuildTbel())).willReturn(alarmDetailsScriptMock); + given(alarmServiceMock.findLatestActiveByOriginatorAndTypeAsync(tenantId, msgOriginator, config.getAlarmType())).willReturn(FluentFuture.from(immediateFuture(null))); var expectedException = new ExecutionException("Failed to execute script.", new RuntimeException("Something went wrong!")); - given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(Futures.immediateFailedFuture(expectedException)); + given(alarmDetailsScriptMock.executeJsonAsync(incomingMsg)).willReturn(immediateFailedFuture(expectedException)); nodeSpy.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java new file mode 100644 index 0000000000..6eb7b6233b --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -0,0 +1,944 @@ +/** + * 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.rule.engine.ai; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.FluentFuture; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.request.ResponseFormat; +import dev.langchain4j.model.chat.request.ResponseFormatType; +import dev.langchain4j.model.chat.request.json.JsonObjectSchema; +import dev.langchain4j.model.chat.request.json.JsonSchema; +import dev.langchain4j.model.chat.response.ChatResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; +import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; +import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; +import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.model.AiModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.dao.exception.DataValidationException; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Stream; + +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class TbAiNodeTest { + + @Mock + TbContext ctxMock; + @Mock + AiModelService aiModelServiceMock; + @Mock + RuleEngineAiChatModelService aiChatModelServiceMock; + + TbAiNode aiNode; + TbAiNodeConfiguration config; + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + AiModelId modelId = new AiModelId(UUID.randomUUID()); + RuleNodeId ruleNodeId = new RuleNodeId(UUID.randomUUID()); + + RuleNode ruleNode; + + AiModel model; + AiModelConfig modelConfig; + + boolean externalNodeForceAck = false; + + @BeforeEach + void setup() { + aiNode = new TbAiNode(); + config = new TbAiNodeConfiguration(); + + modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(new OpenAiProviderConfig("test-api-key")) + .modelId("gpt-4o") + .temperature(0.5) + .topP(0.3) + .frequencyPenalty(0.1) + .presencePenalty(0.2) + .maxOutputTokens(1000) + .timeoutSeconds(100) + .maxRetries(2) + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + lenient().when(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).thenReturn(Optional.of(model)); + lenient().when(aiModelServiceMock.findAiModelByTenantIdAndIdAsync(tenantId, modelId)).thenReturn(FluentFuture.from(immediateFuture(Optional.of(model)))); + + ruleNode = new RuleNode(); + ruleNode.setId(ruleNodeId); + ruleNode.setName("Test AI node"); + lenient().when(ctxMock.getSelf()).thenReturn(ruleNode); + + lenient().when(ctxMock.isExternalNodeForceAck()).thenReturn(externalNodeForceAck); + lenient().when(ctxMock.getTenantId()).thenReturn(tenantId); + lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock); + lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock); + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor()); + } + + @Test + void givenDefaultConfig_whenCalled_thenSetsCorrectValues() { + // GIVEN-WHEN + config = config.defaultConfiguration(); + + // THEN + assertThat(config.getModelId()).isNull(); + assertThat(config.getSystemPrompt()).isEqualTo( + "You are a helpful AI assistant. Your primary function is to process the user's request and respond with a valid JSON object. " + + "Do not include any text, explanations, or markdown formatting before or after the JSON output." + ); + assertThat(config.getUserPrompt()).isNull(); + assertThat(config.getResponseFormat()).isEqualTo(new TbJsonResponseFormat()); + assertThat(config.getTimeoutSeconds()).isEqualTo(60); + assertThat(config.isForceAck()).isTrue(); + } + + /* -- Node initialization tests -- */ + + @Test + void givenNullModelId_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setModelId(null); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasRootCauseInstanceOf(DataValidationException.class) + .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: modelId must not be null") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + @ParameterizedTest + @MethodSource("invalidSystemPrompts") + void givenInvalidSystemPrompt_whenInit_thenThrowsUnrecoverableTbNodeException(String invalidSystemPrompt) { + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(invalidSystemPrompt); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("'" + ruleNode.getName() + "' node configuration is invalid: systemPrompt"); + } + + static Stream invalidSystemPrompts() { + String tooLongString = "a".repeat(10_001); + return Stream.of( + Arguments.of(""), + Arguments.of(" "), + Arguments.of(tooLongString) + ); + } + + @ParameterizedTest + @MethodSource("validSystemPrompts") + void givenValidSystemPrompt_whenInit_thenInitializesSuccessfully(String validSystemPrompt) { + // GIVEN + config = constructValidConfig(); + config.setSystemPrompt(validSystemPrompt); + + // WHEN-THEN + assertThatNoException().isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + static Stream validSystemPrompts() { + String longString = "a".repeat(10_000); + return Stream.of( + Arguments.of((String) null), + Arguments.of("a"), + Arguments.of("Test system prompt"), + Arguments.of(longString) + ); + } + + @ParameterizedTest + @MethodSource("invalidUserPrompts") + void givenInvalidUserPrompt_whenInit_thenThrowsUnrecoverableTbNodeException(String invalidUserPrompt) { + // GIVEN + config = constructValidConfig(); + config.setUserPrompt(invalidUserPrompt); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("'" + ruleNode.getName() + "' node configuration is invalid: userPrompt"); + } + + static Stream invalidUserPrompts() { + String tooLongString = "a".repeat(10_001); + return Stream.of( + Arguments.of((String) null), + Arguments.of(""), + Arguments.of(" "), + Arguments.of(tooLongString) + ); + } + + @ParameterizedTest + @MethodSource("validUserPrompts") + void givenValidUserPrompt_whenInit_thenInitializesSuccessfully(String validUserPrompt) { + // GIVEN + config = constructValidConfig(); + config.setUserPrompt(validUserPrompt); + + // WHEN-THEN + assertThatNoException().isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + static Stream validUserPrompts() { + String longString = "a".repeat(10_000); + return Stream.of( + Arguments.of("a"), + Arguments.of("Test user prompt"), + Arguments.of(longString) + ); + } + + @Test + void givenNullResponseFormat_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(null); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasRootCauseInstanceOf(DataValidationException.class) + .hasRootCauseMessage("'" + ruleNode.getName() + "' node configuration is invalid: responseFormat must not be null") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + @ParameterizedTest + @ValueSource(ints = {Integer.MIN_VALUE, 0, 601, Integer.MAX_VALUE}) + void givenInvalidTimeoutSeconds_whenInit_thenThrowsUnrecoverableTbNodeException(int invalidTimeoutSeconds) { + // GIVEN + config = constructValidConfig(); + config.setTimeoutSeconds(invalidTimeoutSeconds); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("'" + ruleNode.getName() + "' node configuration is invalid: timeoutSeconds"); + } + + @ParameterizedTest + @ValueSource(ints = {1, 60, 600}) + void givenValidTimeoutSeconds_whenInit_thenInitializesSuccessfully(int validTimeoutSeconds) { + // GIVEN + config = constructValidConfig(); + config.setTimeoutSeconds(validTimeoutSeconds); + + // WHEN-THEN + assertThatNoException().isThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))); + } + + @Test + void givenAiModelNotFound_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.empty()); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] was not found") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + TbAiNodeConfiguration constructValidConfig() { + var config = new TbAiNodeConfiguration(); + config.setModelId(modelId); + config.setSystemPrompt("Test system prompt"); + config.setUserPrompt("Test user prompt"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(60); + config.setForceAck(true); + return config; + } + + + @Test + void givenJsonModeConfiguredButModelDoesNotSupportIt_whenInit_thenThrowsUnrecoverableTbNodeException() { + // GIVEN + config = constructValidConfig(); + config.setResponseFormat(new TbJsonResponseFormat()); + + modelConfig = AnthropicChatModelConfig.builder() + .providerConfig(new AnthropicProviderConfig("test-api-key")) + .modelId("claude-sonnet-4-0") + .build(); + + model = AiModel.builder() + .tenantId(tenantId) + .name("Test model") + .configuration(modelConfig) + .build(); + + model.setId(modelId); + model.setVersion(1L); + model.setCreatedTime(123L); + + given(aiModelServiceMock.findAiModelByTenantIdAndId(tenantId, modelId)).willReturn(Optional.of(model)); + + // WHEN-THEN + assertThatThrownBy(() -> aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .hasMessage("[" + tenantId + "] AI model with ID: [" + modelId + "] does not support 'JSON' response format") + .matches(e -> ((TbNodeException) e).isUnrecoverable()); + } + + /* -- Message processing tests -- */ + + @Test + void givenForceAckIsFalse_whenOnMsg_thenTellSuccessIsCalled() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().tellSuccess(any()); + + then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class)); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + void givenLocalForceAckIsFalseButExternalIsTold_whenOnMsg_thenEnqueuesForTellNext() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + given(ctxMock.isExternalNodeForceAck()).willReturn(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + void givenForceAckIsTrue_whenOnMsg_thenEnqueuesForTellNext() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + + then(ctxMock).should(never()).tellSuccess(any()); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + + @Test + void givenOnlyUserPromptConfigured_whenOnMsg_thenRequestContainsOnlyUserMessage() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt(null); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(1); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(UserMessage.from("Tell me a joke")); + return true; + }) + ); + } + + @Test + void givenSystemAndUserPromptsConfigured_whenOnMsg_thenRequestContainsBothSystemAndUserMessages() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from("Respond with valid JSON")); + assertThat(actualChatRequest.messages().get(1)).isEqualTo(UserMessage.from("Tell me a joke")); + return true; + }) + ); + } + + @Test + void givenTemplatedPrompts_whenOnMsg_thenRequestContainsSubstitutedMessages() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with $[responseFormat]"); + config.setUserPrompt("Tell me a joke about ${jokeIdea}"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data("{\"responseFormat\":\"valid JSON\"}") + .metaData(new TbMsgMetaData(Map.of("jokeIdea", "JSON"))) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"joke\":\"Why did the JSON go to therapy?\",\"punchline\":\"Because it had too many unresolved references!\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync(any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from("Respond with valid JSON")); + assertThat(actualChatRequest.messages().get(1)).isEqualTo(UserMessage.from("Tell me a joke about JSON")); + return true; + }) + ); + } + + @Test + void givenNodeTimeoutIsConfigured_whenOnMsg_thenRequestUsesNodeTimeout() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig.timeoutSeconds()).isEqualTo(config.getTimeoutSeconds()); + return true; + }), any() + ); + } + + @Test + void givenAnyConfig_whenOnMsg_thenRequestHasRetriesDisabled() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig.maxRetries()).isZero(); + return true; + }), any() + ); + } + + @Test + void givenTextResponseFormatAndNonJsonResponse_whenOnMsg_thenWrapsResponseInJsonObject() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setUserPrompt("Tell me a joke about JSON"); + config.setResponseFormat(new TbTextResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + Why did the JSON file break up with the XML file? + Because it found someone less complicated and more flexible!""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(ctxMock).should().tellSuccess(argThat( + resultMsg -> resultMsg.getData().equals(JacksonUtil.newObjectNode().put("response", chatResponse.aiMessage().text()).toString())) + ); + } + + @Test + void givenModelIsConfigured_whenOnMsg_thenRequestUsesCorrectModelConfig() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig) + .usingRecursiveComparison() + .ignoringFields("timeoutSeconds", "maxRetries") + .isEqualTo(modelConfig); + return true; + }), + any() + ); + } + + @Test + void givenTextResponseFormat_whenOnMsg_thenRequestResponseFormatIsNull() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbTextResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + Why did the JSON file break up with the XML file? + Because it found someone less complicated and more flexible!""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.responseFormat()).isNull(); + return true; + }) + ); + } + + @Test + void givenJsonResponseFormat_whenOnMsg_thenRequestResponseFormatIsJson() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + Why did the JSON file break up with the XML file? + Because it found someone less complicated and more flexible!""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.JSON).build()); + return true; + }) + ); + } + + @Test + void givenJsonSchemaResponseFormat_whenOnMsg_thenRequestResponseFormatIsJsonWithSchema() throws TbNodeException { + // GIVEN + var jsonSchema = """ + { + "title": "Joke", + "type": "object", + "properties": { + "joke": { + "type": "string" + }, + "punchline": { + "type": "string" + } + }, + "required": [ + "joke", + "punchline" + ] + } + """; + + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonSchemaResponseFormat((ObjectNode) JacksonUtil.toJsonNode(jsonSchema))); + config.setTimeoutSeconds(10); + config.setForceAck(true); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from(""" + { + "joke": "Why do programmers prefer JSON over XML?", + "punchline": "Because it’s less taxing to read!" + }""")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + var expectedJsonSchema = JsonSchema.builder() + .name("Joke") + .rootElement(JsonObjectSchema.builder() + .addStringProperty("joke") + .addStringProperty("punchline") + .required("joke", "punchline") + .additionalProperties(true) + .build()) + .build(); + + then(aiChatModelServiceMock).should().sendChatRequestAsync( + any(), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.JSON).jsonSchema(expectedJsonSchema).build()); + return true; + }) + ); + } + + @Test + void givenComprehensiveConfig_whenOnMsg_thenProcessesMessageAndTellsSuccessCorrectly() throws TbNodeException { + // GIVEN + config.setModelId(modelId); + config.setSystemPrompt("Respond with valid JSON"); + config.setUserPrompt("Tell me a joke"); + config.setResponseFormat(new TbJsonResponseFormat()); + config.setTimeoutSeconds(10); + config.setForceAck(false); + + aiNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + var msg = TbMsg.newMsg() + .originator(deviceId) + .data(TbMsg.EMPTY_JSON_OBJECT) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var chatResponse = ChatResponse.builder() + .aiMessage(AiMessage.from("{\"type\":\"joke\",\"setup\":\"Why did the scarecrow win an award?\",\"punchline\":\"Because he was outstanding in his field.\"}")) + .build(); + + given(aiChatModelServiceMock.sendChatRequestAsync(any(), any())).willReturn(FluentFuture.from(immediateFuture(chatResponse))); + + // WHEN + aiNode.onMsg(ctxMock, msg); + + // THEN + then(aiChatModelServiceMock).should().sendChatRequestAsync( + argThat(actualChatModelConfig -> { + assertThat(actualChatModelConfig) + .usingRecursiveComparison() + .ignoringFields("timeoutSeconds", "maxRetries") + .isEqualTo(modelConfig); + assertThat(actualChatModelConfig.timeoutSeconds()).isEqualTo(config.getTimeoutSeconds()); + assertThat(actualChatModelConfig.maxRetries()).isEqualTo(0); + return true; + }), + argThat(actualChatRequest -> { + assertThat(actualChatRequest.messages()).hasSize(2); + assertThat(actualChatRequest.messages().get(0)).isEqualTo(SystemMessage.from("Respond with valid JSON")); + assertThat(actualChatRequest.messages().get(1)).isEqualTo(UserMessage.from("Tell me a joke")); + assertThat(actualChatRequest.responseFormat()).isEqualTo(ResponseFormat.builder().type(ResponseFormatType.JSON).build()); + return true; + }) + ); + + then(ctxMock).should().tellSuccess(argThat(resultMsg -> + resultMsg.getData().equals(chatResponse.aiMessage().text()) && + resultMsg.getMetaData().equals(msg.getMetaData()) && + resultMsg.getType().equals(msg.getType()) && + resultMsg.getOriginator().equals(msg.getOriginator())) + ); + + then(ctxMock).should(never()).enqueueForTellNext(any(), any(String.class)); + then(ctxMock).should(never()).enqueueForTellFailure(any(), any(Throwable.class)); + then(ctxMock).should(never()).tellNext(any(), any(String.class)); + then(ctxMock).should(never()).tellFailure(any(), any()); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java index 1e877b80dc..5e93f6910f 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java @@ -212,40 +212,45 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest { assertThatNoException().isThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))); } - @Test - public void givenClientIdIsTooLong_whenInit_thenThrowsException() { - String invalidClientId = "vhfrbeb38ygwfwrgfwefgterhytjytj"; - mqttNodeConfig.setClientId(invalidClientId); + @ParameterizedTest + @MethodSource("provideInvalidClientIdScenarios") + public void givenInvalidClientId_whenInit_thenThrowsException(MqttVersion version, int maxLength, int repeat, String serviceId, boolean appendSuffix) { + String baseClientId = "x".repeat(repeat); + mqttNodeConfig.setClientId(baseClientId); + mqttNodeConfig.setAppendClientIdSuffix(appendSuffix); + mqttNodeConfig.setProtocolVersion(version); given(ctxMock.getTenantId()).willReturn(TENANT_ID); given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID)); + String clientId = appendSuffix ? baseClientId + "_" + serviceId : baseClientId; + if (appendSuffix) { + given(ctxMock.getServiceId()).willReturn(serviceId); + } + + String expectedMessage = "The length of Client ID cannot be longer than " + maxLength + ", but current length is " + clientId.length() + "."; + assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))) .isInstanceOf(TbNodeException.class) - .hasMessage("Client ID is too long '" + invalidClientId + "'. " + - "The length of Client ID cannot be longer than 23, but current length is " + invalidClientId.length() + ".") + .hasMessage(expectedMessage) .extracting(e -> ((TbNodeException) e).isUnrecoverable()) .isEqualTo(true); } - @Test - public void givenClientIdIsOkAndAppendClientIdSuffixIsTrue_whenInit_thenClientIdBecomesInvalidAndThrowsException() { - String validClientId = "fertjnhnjj4ge"; - mqttNodeConfig.setClientId("fertjnhnjj4ge"); - mqttNodeConfig.setAppendClientIdSuffix(true); + private static Stream provideInvalidClientIdScenarios() { + return Stream.of( + // MQTT_5, too long clientId + Arguments.of(MqttVersion.MQTT_5, 256, 257, null, false), - given(ctxMock.getTenantId()).willReturn(TENANT_ID); - given(ctxMock.getSelf()).willReturn(new RuleNode(RULE_NODE_ID)); - String serviceId = "test-service"; - given(ctxMock.getServiceId()).willReturn(serviceId); + // MQTT_5, base + suffix exceeds + Arguments.of(MqttVersion.MQTT_5, 256, 250, "test-service", true), - String resultedClientId = validClientId + "_" + serviceId; - assertThatThrownBy(() -> mqttNode.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(mqttNodeConfig)))) - .isInstanceOf(TbNodeException.class) - .hasMessage("Client ID is too long '" + resultedClientId + "'. " + - "The length of Client ID cannot be longer than 23, but current length is " + resultedClientId.length() + ".") - .extracting(e -> ((TbNodeException) e).isUnrecoverable()) - .isEqualTo(true); + // MQTT_3_1, too long clientId + Arguments.of(MqttVersion.MQTT_3_1, 23, 24, null, false), + + // MQTT_3_1, base + suffix exceeds + Arguments.of(MqttVersion.MQTT_3_1, 23, 5, "verylongservicename", true) + ); } @Test diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java index 433d5d4673..c8c1553fa5 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java @@ -34,6 +34,9 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.credentials.CertPemCredentials; import org.thingsboard.rule.engine.mqtt.TbMqttNodeConfiguration; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -77,7 +80,10 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { @Test public void verifyPrepareMqttClientConfigMethodWithAzureIotHubSasCredentials() throws Exception { - AzureIotHubSasCredentials credentials = new AzureIotHubSasCredentials(); + var fixedClock = Clock.fixed(Instant.parse("2030-01-01T00:00:00Z"), ZoneOffset.UTC); + azureIotHubNode.setClock(fixedClock); + + var credentials = new AzureIotHubSasCredentials(); credentials.setSasKey("testSasKey"); credentials.setCaCert("test-ca-cert.pem"); azureIotHubNodeConfig.setCredentials(credentials); @@ -89,7 +95,7 @@ public class TbAzureIotHubNodeTest extends AbstractRuleNodeUpgradeTest { azureIotHubNode.prepareMqttClientConfig(mqttClientConfig); assertThat(mqttClientConfig.getUsername()).isEqualTo(AzureIotHubUtil.buildUsername(azureIotHubNodeConfig.getHost(), mqttClientConfig.getClientId())); - assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey())); + assertThat(mqttClientConfig.getPassword()).isEqualTo(AzureIotHubUtil.buildSasToken(azureIotHubNodeConfig.getHost(), credentials.getSasKey(), fixedClock)); } @Test diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java index 4cbc091bdc..16698b2841 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/TenantIdLoaderTest.java @@ -29,6 +29,7 @@ import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; import org.thingsboard.rule.engine.api.RuleEngineRpcService; import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; @@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.TbResource; 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.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -69,6 +71,7 @@ import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.widget.WidgetType; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiModelService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.customer.CustomerService; @@ -94,6 +97,7 @@ import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; @@ -164,10 +168,11 @@ public class TenantIdLoaderTest { private CalculatedFieldService calculatedFieldService; @Mock private JobService jobService; + @Mock + private AiModelService aiModelService; private TenantId tenantId; private TenantProfileId tenantProfileId; - private NotificationId notificationId; private AbstractListeningExecutor dbExecutor; @BeforeEach @@ -179,9 +184,8 @@ public class TenantIdLoaderTest { } }; dbExecutor.init(); - this.tenantId = new TenantId(UUID.randomUUID()); + this.tenantId = TenantId.fromUUID(UUID.randomUUID()); this.tenantProfileId = new TenantProfileId(UUID.randomUUID()); - this.notificationId = new NotificationId(UUID.randomUUID()); when(ctx.getTenantId()).thenReturn(tenantId); @@ -199,6 +203,7 @@ public class TenantIdLoaderTest { switch (entityType) { case TENANT: case NOTIFICATION: + case ADMIN_SETTINGS: break; case CUSTOMER: Customer customer = new Customer(); @@ -429,6 +434,12 @@ public class TenantIdLoaderTest { when(ctx.getJobService()).thenReturn(jobService); doReturn(job).when(jobService).findJobById(eq(tenantId), any()); break; + case AI_MODEL: + AiModel aiModel = new AiModel(); + aiModel.setTenantId(tenantId); + when(ctx.getAiModelService()).thenReturn(aiModelService); + doReturn(Optional.of(aiModel)).when(aiModelService).findAiModelById(eq(tenantId), any()); + break; default: throw new RuntimeException("Unexpected originator EntityType " + entityType); } @@ -465,7 +476,7 @@ public class TenantIdLoaderTest { @Test public void test_findEntityIdAsync_other_tenant() { - checkTenant(new TenantId(UUID.randomUUID()), false); + checkTenant(TenantId.fromUUID(UUID.randomUUID()), false); } } diff --git a/tools/pom.xml b/tools/pom.xml index 832b6e00ad..1224e73777 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 050e287998..c96f4fddf7 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 2cc3d25695..e32915c51e 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index 0518241889..af17bf703b 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index a1b1cc455b..87c98f9d78 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index e0c638c995..8ddcf992d8 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 42f9bc92d2..09901661a6 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.1.0-RC + 4.2.0-RC transport diff --git a/ui-ngx/package.json b/ui-ngx/package.json index e1cc3a90af..4ba512a024 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.1.0", + "version": "4.2.0", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", @@ -140,6 +140,7 @@ "tinymce": "6.8.5", "rollup": "4.22.4", "@babel/core": "7.25.2", - "esbuild": "0.23.0" + "esbuild": "0.23.0", + "jquery.terminal/coveralls-next/form-data": "4.0.4" } } diff --git a/ui-ngx/patches/@angular+material+18.2.14.patch b/ui-ngx/patches/@angular+material+18.2.14.patch new file mode 100644 index 0000000000..db380983a2 --- /dev/null +++ b/ui-ngx/patches/@angular+material+18.2.14.patch @@ -0,0 +1,61 @@ +diff --git a/node_modules/@angular/material/autocomplete/index.d.ts b/node_modules/@angular/material/autocomplete/index.d.ts +index 1ebc198..f572ea2 100755 +--- a/node_modules/@angular/material/autocomplete/index.d.ts ++++ b/node_modules/@angular/material/autocomplete/index.d.ts +@@ -220,6 +220,10 @@ export declare interface MatAutocompleteDefaultOptions { + requireSelection?: boolean; + /** Class or list of classes to be applied to the autocomplete's overlay panel. */ + overlayPanelClass?: string | string[]; ++ ++ backdropClass?: string; ++ ++ hasBackdrop?: boolean; + /** Wheter icon indicators should be hidden for single-selection. */ + hideSingleSelectionIndicator?: boolean; + } +diff --git a/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs b/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs +index 193f5e4..833446a 100755 +--- a/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs ++++ b/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs +@@ -670,6 +670,8 @@ export class MatAutocompleteTrigger { + scrollStrategy: this._scrollStrategy(), + width: this._getPanelWidth(), + direction: this._dir ?? undefined, ++ hasBackdrop: this._defaults?.hasBackdrop, ++ backdropClass: this._defaults?.backdropClass, + panelClass: this._defaults?.overlayPanelClass, + }); + } +diff --git a/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs b/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs +index 3d919a4..5fd6b4d 100755 +--- a/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs ++++ b/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs +@@ -41,6 +41,7 @@ export function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY() { + autoSelectActiveOption: false, + hideSingleSelectionIndicator: false, + requireSelection: false, ++ hasBackdrop: false, + }; + } + /** Autocomplete component. */ +diff --git a/node_modules/@angular/material/fesm2022/autocomplete.mjs b/node_modules/@angular/material/fesm2022/autocomplete.mjs +index e3d0253..36cd6d7 100755 +--- a/node_modules/@angular/material/fesm2022/autocomplete.mjs ++++ b/node_modules/@angular/material/fesm2022/autocomplete.mjs +@@ -65,6 +65,7 @@ function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY() { + autoSelectActiveOption: false, + hideSingleSelectionIndicator: false, + requireSelection: false, ++ hasBackdrop: false, + }; + } + /** Autocomplete component. */ +@@ -926,6 +927,8 @@ class MatAutocompleteTrigger { + scrollStrategy: this._scrollStrategy(), + width: this._getPanelWidth(), + direction: this._dir ?? undefined, ++ hasBackdrop: this._defaults?.hasBackdrop, ++ backdropClass: this._defaults?.backdropClass, + panelClass: this._defaults?.overlayPanelClass, + }); + } diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 7fe99a56fa..ca8094430a 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.1.0-RC + 4.2.0-RC thingsboard org.thingsboard diff --git a/ui-ngx/src/app/core/auth/auth.actions.ts b/ui-ngx/src/app/core/auth/auth.actions.ts index dedd67bcb9..3e2b22ff63 100644 --- a/ui-ngx/src/app/core/auth/auth.actions.ts +++ b/ui-ngx/src/app/core/auth/auth.actions.ts @@ -18,6 +18,7 @@ import { Action } from '@ngrx/store'; import { AuthUser, User } from '@shared/models/user.model'; import { AuthPayload } from '@core/auth/auth.models'; import { UserSettings } from '@shared/models/user-settings.models'; +import { TrendzSettings } from "@shared/models/trendz-settings.models"; export enum AuthActionTypes { AUTHENTICATED = '[Auth] Authenticated', @@ -31,6 +32,7 @@ export enum AuthActionTypes { UPDATE_OPENED_MENU_SECTION = '[Preferences] Update Opened Menu Section', PUT_USER_SETTINGS = '[Preferences] Put user settings', DELETE_USER_SETTINGS = '[Preferences] Delete user settings', + UPDATE_TRENDZ_SETTINGS = '[Auth] Update Trendz Settings', } export class ActionAuthAuthenticated implements Action { @@ -97,7 +99,13 @@ export class ActionPreferencesDeleteUserSettings implements Action { constructor(readonly payload: Array>) {} } +export class ActionAuthUpdateTrendzSettings implements Action { + readonly type = AuthActionTypes.UPDATE_TRENDZ_SETTINGS; + + constructor(readonly payload: TrendzSettings) {} +} + export type AuthActions = ActionAuthAuthenticated | ActionAuthUnauthenticated | ActionAuthLoadUser | ActionAuthUpdateUserDetails | ActionAuthUpdateLastPublicDashboardId | ActionAuthUpdateHasRepository | ActionPreferencesUpdateOpenedMenuSection | ActionPreferencesPutUserSettings | ActionPreferencesDeleteUserSettings | - ActionAuthUpdateAuthUser | ActionUpdateMobileQrCodeEnabled; + ActionAuthUpdateAuthUser | ActionUpdateMobileQrCodeEnabled | ActionAuthUpdateTrendzSettings; diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index fde778284d..785f40ce3c 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -99,6 +99,9 @@ export const authReducer = ( action.payload.forEach(path => unset(userSettings, path)); return { ...state, ...{ userSettings }}; + case AuthActionTypes.UPDATE_TRENDZ_SETTINGS: + return { ...state, trendzSettings: action.payload }; + default: return state; } diff --git a/ui-ngx/src/app/core/http/ai-model.service.ts b/ui-ngx/src/app/core/http/ai-model.service.ts new file mode 100644 index 0000000000..64e003c30e --- /dev/null +++ b/ui-ngx/src/app/core/http/ai-model.service.ts @@ -0,0 +1,54 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { AiModel, AiModelWithUserMsg, CheckConnectivityResult } from '@shared/models/ai-model.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { PageData } from '@shared/models/page/page-data'; + +@Injectable({ + providedIn: 'root' +}) +export class AiModelService { + + constructor( + private http: HttpClient + ) {} + + public saveAiModel(aiModel: AiModel, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model', aiModel, defaultHttpOptionsFromConfig(config)); + } + + public getAiModels(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/ai/model${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public getAiModelById(aiModelId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public deleteAiModel(aiModelId: string, config?: RequestConfig) { + return this.http.delete(`/api/ai/model/${aiModelId}`, defaultHttpOptionsFromConfig(config)); + } + + public checkConnectivity(aiModelWithUserMsg: AiModelWithUserMsg, config?: RequestConfig): Observable { + return this.http.post('/api/ai/model/chat', aiModelWithUserMsg, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/http/entity.service.ts b/ui-ngx/src/app/core/http/entity.service.ts index 01fed28bf3..572d6cc473 100644 --- a/ui-ngx/src/app/core/http/entity.service.ts +++ b/ui-ngx/src/app/core/http/entity.service.ts @@ -99,6 +99,7 @@ import { ResourceService } from '@core/http/resource.service'; import { OAuth2Service } from '@core/http/oauth2.service'; import { MobileAppService } from '@core/http/mobile-app.service'; import { PlatformType } from '@shared/models/oauth2.models'; +import { AiModelService } from '@core/http/ai-model.service'; @Injectable({ providedIn: 'root' @@ -131,6 +132,7 @@ export class EntityService { private resourceService: ResourceService, private oauth2Service: OAuth2Service, private mobileAppService: MobileAppService, + private aiModelService: AiModelService, ) { } private getEntityObservable(entityType: EntityType, entityId: string, @@ -183,6 +185,9 @@ export class EntityService { case EntityType.MOBILE_APP_BUNDLE: observable = this.mobileAppService.getMobileAppBundleInfoById(entityId, config); break; + case EntityType.AI_MODEL: + observable = this.aiModelService.getAiModelById(entityId, config); + break; } return observable; } @@ -485,6 +490,10 @@ export class EntityService { pageLink.sortOrder.property = 'title'; entitiesObservable = this.mobileAppService.getTenantMobileAppBundleInfos(pageLink, config); break; + case EntityType.AI_MODEL: + pageLink.sortOrder.property = 'name'; + entitiesObservable = this.aiModelService.getAiModels(pageLink, config); + break; } return entitiesObservable; } @@ -1022,11 +1031,7 @@ export class EntityService { const stateEntityId = stateEntityInfo.entityId; switch (filter.type) { case AliasFilterType.singleEntity: - const aliasEntityId = this.resolveAliasEntityId(filter.singleEntity.entityType, filter.singleEntity.id); - result.entityFilter = { - type: AliasFilterType.singleEntity, - singleEntity: aliasEntityId - }; + result.entityFilter = deepClone(filter); return of(result); case AliasFilterType.entityList: result.entityFilter = deepClone(filter); @@ -1077,9 +1082,8 @@ export class EntityService { rootEntityId = filter.rootEntity.id; } if (rootEntityType && rootEntityId) { - const queryRootEntityId = this.resolveAliasEntityId(rootEntityType, rootEntityId); result.entityFilter = deepClone(filter); - result.entityFilter.rootEntity = queryRootEntityId; + result.entityFilter.rootEntity = {entityType: rootEntityType, id: rootEntityId}; return of(result); } else { return of(result); @@ -1378,44 +1382,9 @@ export class EntityService { if (!entityId) { entityId = filter.defaultStateEntity; } - if (entityId) { - entityId = this.resolveAliasEntityId(entityId.entityType, entityId.id); - } return {entityId}; } - private resolveAliasEntityId(entityType: EntityType | AliasEntityType, id: string): EntityId { - const entityId: EntityId = { - entityType, - id - }; - if (entityType === AliasEntityType.CURRENT_CUSTOMER) { - const authUser = getCurrentAuthUser(this.store); - entityId.entityType = EntityType.CUSTOMER; - if (authUser.authority === Authority.CUSTOMER_USER) { - entityId.id = authUser.customerId; - } - } else if (entityType === AliasEntityType.CURRENT_TENANT){ - const authUser = getCurrentAuthUser(this.store); - entityId.entityType = EntityType.TENANT; - entityId.id = authUser.tenantId; - } else if (entityType === AliasEntityType.CURRENT_USER){ - const authUser = getCurrentAuthUser(this.store); - entityId.entityType = EntityType.USER; - entityId.id = authUser.userId; - } else if (entityType === AliasEntityType.CURRENT_USER_OWNER){ - const authUser = getCurrentAuthUser(this.store); - if (authUser.authority === Authority.TENANT_ADMIN) { - entityId.entityType = EntityType.TENANT; - entityId.id = authUser.tenantId; - } else if (authUser.authority === Authority.CUSTOMER_USER) { - entityId.entityType = EntityType.CUSTOMER; - entityId.id = authUser.customerId; - } - } - return entityId; - } - private createDatasourceFromSubscriptionInfo(subscriptionInfo: SubscriptionInfo): Datasource { subscriptionInfo = this.validateSubscriptionInfo(subscriptionInfo); let datasource: Datasource = null; diff --git a/ui-ngx/src/app/core/http/public-api.ts b/ui-ngx/src/app/core/http/public-api.ts index c28d80d173..63cc393ce6 100644 --- a/ui-ngx/src/app/core/http/public-api.ts +++ b/ui-ngx/src/app/core/http/public-api.ts @@ -48,3 +48,4 @@ export * from './user-settings.service'; export * from './widget.service'; export * from './usage-info.service'; export * from './trendz-settings.service' +export * from './ai-model.service' diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index 607c5c6dff..4277f96747 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -105,7 +105,8 @@ export enum MenuId { otaUpdates = 'otaUpdates', version_control = 'version_control', api_usage = 'api_usage', - trendz_settings = 'trendz_settings' + trendz_settings = 'trendz_settings', + ai_models = 'ai_models' } declare type MenuFilter = (authState: AuthState) => boolean; @@ -286,6 +287,16 @@ export const menuSectionMap = new Map([ icon: 'mdi:message-cog' } ], + [ + MenuId.ai_models, + { + id: MenuId.ai_models, + name: 'ai-models.ai-models', + type: 'link', + path: '/settings/ai-models', + icon: 'auto_awesome' + } + ], [ MenuId.mobile_center, { @@ -856,7 +867,8 @@ const defaultUserMenuMap = new Map([ {id: MenuId.notification_settings}, {id: MenuId.repository_settings}, {id: MenuId.auto_commit_settings}, - {id: MenuId.trendz_settings} + {id: MenuId.trendz_settings}, + {id: MenuId.ai_models} ] }, { diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 70432e8cdf..0890b9e623 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -336,6 +336,7 @@ import * as DatapointsLimitComponent from '@shared/components/time/datapoints-li import * as AggregationTypeSelectComponent from '@shared/components/time/aggregation/aggregation-type-select.component'; import * as AggregationOptionsConfigComponent from '@shared/components/time/aggregation/aggregation-options-config-panel.component'; import * as IntervalOptionsConfigPanelComponent from '@shared/components/time/interval-options-config-panel.component'; +import * as AIModelDialogComponent from '@home/components/ai-model/ai-model-dialog.component'; import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; @@ -668,7 +669,8 @@ class ModulesMap implements IModulesMap { '@home/components/dashboard-page/dashboard-image-dialog.component': DashboardImageDialogComponent, '@home/components/widget/widget-container.component': WidgetContainerComponent, '@home/components/profile/queue/tenant-profile-queues.component': TenantProfileQueuesComponent, - '@home/components/queue/queue-form.component': QueueFormComponent + '@home/components/queue/queue-form.component': QueueFormComponent, + '@home/components/ai-model/ai-model-dialog.component': AIModelDialogComponent, }; init(): Observable { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html new file mode 100644 index 0000000000..860e615410 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -0,0 +1,293 @@ + + +

{{ dialogTitle | translate }}

+ +
+ +
+ + +
+
+
+
+ + ai-models.name + + + + {{ 'ai-models.name-required' | translate }} + + + {{ 'ai-models.name-max-length' | translate }} + + +
+
+
+
ai-models.provider
+
+ + ai-models.ai-provider + + + {{providerTranslationMap.get(provider) | translate}} + + + +
+ @if (providerFieldsList.includes('personalAccessToken')) { + + ai-models.personal-access-token + + + + {{ 'ai-models.personal-access-token-required' | translate }} + + + } + @if (providerFieldsList.includes('projectId')) { + + ai-models.project-id + + + {{ 'ai-models.project-id-required' | translate }} + + + } + @if (providerFieldsList.includes('location')) { + + ai-models.location + + + {{ 'ai-models.location-required' | translate }} + + + } + @if (providerFieldsList.includes('serviceAccountKey')) { + + + } + @if (providerFieldsList.includes('endpoint')) { + + ai-models.endpoint + + + {{ 'ai-models.endpoint-required' | translate }} + + + } + @if (providerFieldsList.includes('serviceVersion')) { + + ai-models.service-version + + + } + @if (providerFieldsList.includes('apiKey')) { + + ai-models.api-key + + + + {{ 'ai-models.api-key-required' | translate }} + + + } + @if (providerFieldsList.includes('region')) { + + ai-models.region + + + {{ 'ai-models.region-required' | translate }} + + + } + @if (providerFieldsList.includes('accessKeyId')) { + + ai-models.access-key-id + + + {{ 'ai-models.access-key-id-required' | translate }} + + + } + @if (providerFieldsList.includes('secretAccessKey')) { + + ai-models.secret-access-key + + + + {{ 'ai-models.secret-access-key-required' | translate }} + + + } +
+
+
+
+
ai-models.configuration
+
+
+ + +
+ @if (modelFieldsList.includes('temperature')) { +
+
+ {{ 'ai-models.temperature' | translate }} +
+ + + + warning + + +
+ } + @if (modelFieldsList.includes('topP')) { +
+
+ {{ 'ai-models.top-p' | translate }} +
+ + + + warning + + +
+ } + @if (modelFieldsList.includes('topK')) { +
+
+ {{ 'ai-models.top-k' | translate }} +
+ + + + warning + + +
+ } + @if (modelFieldsList.includes('presencePenalty')) { +
+
+ {{ 'ai-models.presence-penalty' | translate }} +
+ + + +
+ } + + @if (modelFieldsList.includes('frequencyPenalty')) { +
+
+ {{ 'ai-models.frequency-penalty' | translate }} +
+ + + +
+ } + @if (modelFieldsList.includes('maxOutputTokens')) { +
+
+ {{ 'ai-models.max-output-tokens' | translate }} +
+ + + + warning + + +
+ } +
+
+
+
+
+
+ + + + +
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss new file mode 100644 index 0000000000..c55453c388 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.scss @@ -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. + */ + +:host { + width: 850px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; +} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts new file mode 100644 index 0000000000..db5d1d7e23 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -0,0 +1,167 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { Observable, of } from 'rxjs'; +import { StepperOrientation } from '@angular/cdk/stepper'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { EntityType } from '@shared/models/entity-type.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + AiModel, + AiModelMap, + AiProvider, + AiProviderTranslations, + ModelType, + ProviderFieldsAllList +} from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; +import { map } from 'rxjs/operators'; + +export interface AIModelDialogData { + AIModel?: AiModel; + isAdd?: boolean; +} + +@Component({ + selector: 'tb-ai-model-dialog', + templateUrl: './ai-model-dialog.component.html', + styleUrls: ['./ai-model-dialog.component.scss'] +}) +export class AIModelDialogComponent extends DialogComponent { + + readonly entityType = EntityType; + + selectedIndex = 0; + + dialogTitle = 'ai-models.ai-model'; + + stepperOrientation: Observable; + + aiProvider = AiProvider; + providerMap: AiProvider[] = Object.keys(AiProvider) as AiProvider[]; + providerTranslationMap = AiProviderTranslations; + + provider: AiProvider = AiProvider.OPENAI; + + aiModelForms: FormGroup; + + isAdd = false; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private fb: FormBuilder, + private aiModelService: AiModelService, + private dialog: MatDialog) { + super(store, router, dialogRef); + + if (this.data.isAdd) { + this.isAdd = true; + } + + this.provider = this.data.AIModel ? this.data.AIModel.configuration.provider : AiProvider.OPENAI; + + this.aiModelForms = this.fb.group({ + name: [this.data.AIModel ? this.data.AIModel.name : '', [Validators.required, Validators.maxLength(255), Validators.pattern(/.*\S.*/)]], + modelType: [ModelType.CHAT], + configuration: this.fb.group({ + provider: [this.provider, []], + providerConfig: this.fb.group({ + apiKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.apiKey : '', [Validators.required]], + personalAccessToken: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.personalAccessToken : '', [Validators.required]], + endpoint: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.endpoint : '', [Validators.required]], + serviceVersion: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceVersion : ''], + projectId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.projectId : '', [Validators.required]], + location: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.location : '', [Validators.required]], + serviceAccountKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.serviceAccountKey : '', [Validators.required]], + fileName: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.fileName : '', [Validators.required]], + region: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.region : '', [Validators.required]], + accessKeyId: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.accessKeyId : '', [Validators.required]], + secretAccessKey: [this.data.AIModel ? this.data.AIModel.configuration.providerConfig?.secretAccessKey : '', [Validators.required]], + }), + modelId: [this.data.AIModel ? this.data.AIModel.configuration?.modelId : '', [Validators.required]], + temperature: [this.data.AIModel ? this.data.AIModel.configuration?.temperature : null, [Validators.min(0)]], + topP: [this.data.AIModel ? this.data.AIModel.configuration?.topP : null, [Validators.min(0.1), Validators.max(1)]], + topK: [this.data.AIModel ? this.data.AIModel.configuration?.topK : null, [Validators.min(0)]], + frequencyPenalty: [this.data.AIModel ? this.data.AIModel.configuration?.frequencyPenalty : null], + presencePenalty: [this.data.AIModel ? this.data.AIModel.configuration?.presencePenalty : null], + maxOutputTokens: [this.data.AIModel ? this.data.AIModel.configuration?.maxOutputTokens : null, [Validators.min(1)]] + }) + }); + + this.aiModelForms.get('configuration.provider').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((provider: AiProvider) => { + this.provider = provider; + this.aiModelForms.get('configuration.modelId').reset(''); + this.aiModelForms.get('configuration.providerConfig').reset({}); + this.updateValidation(provider); + }) + + this.updateValidation(this.provider); + } + + fetchOptions(searchText: string): Observable> { + const search = searchText ? searchText?.toLowerCase() : ''; + return of(this.provider ? AiModelMap.get(this.provider).modelList || [] : []).pipe( + map(name => name?.filter(option => option.toLowerCase().includes(search))), + ); + } + + private updateValidation(provider: AiProvider) { + ProviderFieldsAllList.forEach(key => { + if (AiModelMap.get(provider).providerFieldsList.includes(key)) { + this.aiModelForms.get('configuration.providerConfig').get(key).enable(); + } else { + this.aiModelForms.get('configuration.providerConfig').get(key).disable(); + } + }) + } + + get providerFieldsList(): string[] { + return AiModelMap.get(this.provider).providerFieldsList; + } + get modelFieldsList(): string[] { + return AiModelMap.get(this.provider).modelFieldsList; + } + + cancel(): void { + this.dialogRef.close(null); + } + + checkConnectivity() { + return this.dialog.open(CheckConnectivityDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + AIModel: this.aiModelForms.value + } + }).afterClosed(); + } + + add(): void { + const aiModel = {...this.data.AIModel, ...this.aiModelForms.value} as AiModel; + this.aiModelService.saveAiModel(aiModel).subscribe(aiModel => this.dialogRef.close(aiModel)); + } +} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html new file mode 100644 index 0000000000..afc881b10b --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html @@ -0,0 +1,57 @@ + + +

ai-models.check-connectivity

+ + +
+
+
+ + +
+
+ check_circle +
+ {{ "ai-models.check-connectivity-success" | translate }} +
+
+
+ cancel +
+
{{ "ai-models.check-connectivity-failed" | translate }}
+ + +
+
+
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss new file mode 100644 index 0000000000..a85d0111de --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.scss @@ -0,0 +1,68 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + width: 560px; + height: 100%; + max-width: 100%; + max-height: 100vh; + display: grid; + + .transparent { + background-color: transparent; + } + + .connection-status { + font-weight: 500; + letter-spacing: 0.25px; + text-align: center; + } + + .connection-icon { + height: 32px; + font-size: 32px; + width: 32px; + margin-bottom: 4px; + } + + .error_msg { + text-align: center; + margin-top: 8px; + font-size: 14px; + line-height: 130%; + letter-spacing: 0.25px; + opacity: 0.9; + } + + .success { + color: #198038; + } + + .error { + color: #D12730; + } + + ::ng-deep { + .json-editor { + .tb-json-object-toolbar { + display: none; + } + .tb-json-panel { + margin: 0; + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts new file mode 100644 index 0000000000..dd2c27cb47 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts @@ -0,0 +1,89 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { AiModel, AiModelWithUserMsg, ModelType } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; + +export interface AIModelDialogData { + AIModel?: AiModel; +} + +@Component({ + selector: 'tb-check-connectivity-dialog', + templateUrl: './check-connectivity-dialog.component.html', + styleUrls: ['./check-connectivity-dialog.component.scss'] +}) +export class CheckConnectivityDialogComponent extends DialogComponent { + + showCheckSuccess = false; + checkErrMsg = ''; + + constructor(protected store: Store, + protected router: Router, + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AIModelDialogData, + private aiModelService: AiModelService) { + super(store, router, dialogRef); + + if (this.data.AIModel) { + const aiModelWithMsg: AiModelWithUserMsg = { + userMessage: { + contents: [ + { + contentType: "TEXT", + text: "What is the capital of Ukraine?" + } + ] + }, + chatModelConfig: { + modelType: ModelType.CHAT, + provider: this.data.AIModel.configuration.provider, + providerConfig: {...this.data.AIModel.configuration.providerConfig}, + modelId: this.data.AIModel.configuration.modelId, + maxRetries: 0, + timeoutSeconds: 20 + } + } + this.aiModelService.checkConnectivity(aiModelWithMsg, { + ignoreErrors: true, + ignoreLoading: true + }).subscribe({ + next: (result) => { + if (result.status === 'SUCCESS') { + this.showCheckSuccess = true; + } else { + try { + this.checkErrMsg = JSON.parse(result.errorDetails); + } catch (e) { + this.checkErrMsg = result.errorDetails; + } + } + }, + error: err => this.checkErrMsg = err.error.message + }); + } + } + + cancel(): void { + this.dialogRef.close(null); + } +} diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html index be29713097..f735491e78 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html @@ -28,9 +28,9 @@
-
-
- +
+
+ alias.name diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss index 0e1847f157..0aa1a0884f 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.scss @@ -19,7 +19,7 @@ .tb-resolve-multiple-switch { padding: 18px 0 0 18px; @media #{$mat-xs} { - padding: 0 0 18px 0; + padding: 0 0 22px 0; } } } diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html index 5c1bd2c219..c067517aff 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html @@ -38,14 +38,14 @@
-
+
{{$index + 1}}.
- + {{ 'entity.alias-required' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts index 7763fd6f03..28a3f09126 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/arguments-table/calculated-field-arguments-table.component.ts @@ -179,7 +179,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces renderer: this.renderer, componentType: CalculatedFieldArgumentPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: isExists ? 'left' : 'right', + preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], context: ctx, isModal: true }); diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts index 107d5c2686..7dddc18dbd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts @@ -261,11 +261,16 @@ export class ManageDashboardStatesDialogComponent const originalState = state; const newStateName = this.getNextDuplicatedName(state.name); if (newStateName) { + const newStateId = newStateName.toLowerCase().replace(/\W/g, '_'); + if (this.states[newStateId]) { + this.stateNames.add(newStateName); + this.duplicateState(null, state); + } const duplicatedStates = deepClone(originalState); const duplicatedWidgets = deepClone(this.widgets); const mainWidgets = {}; const rightWidgets = {}; - duplicatedStates.id = newStateName.toLowerCase().replace(/\W/g, '_'); + duplicatedStates.id = newStateId; duplicatedStates.name = newStateName; duplicatedStates.root = false; this.stateNames.add(duplicatedStates.name); diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index 22dfb34a64..25bb1d9700 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -15,8 +15,8 @@ limitations under the License. --> -
- +
+ alias.filter-type @@ -30,18 +30,21 @@
@@ -49,12 +52,13 @@ - + entity.name-starts-with help @@ -66,6 +70,7 @@ - + alias.state-entity-parameter-name -
- - +
{{ 'alias.default-state-entity' | translate }}
+ @@ -88,10 +93,13 @@ - + asset.name-starts-with help @@ -100,10 +108,13 @@ - + device.name-starts-with help @@ -112,10 +123,13 @@ - + entity-view.name-starts-with help @@ -124,36 +138,51 @@ - + edge.name-starts-with
-
+
alias.root-entity
- + {{ 'alias.root-state-entity' | translate }} -
- - alias.state-entity-parameter-name - - - + @if (filterFormGroup.get('rootStateEntity').value) { +
+ + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ + +
+
+ } @else { + -
- - -
- + } +
+
+
alias.query-options
+
+ relation.direction @@ -161,7 +190,7 @@ - + alias.max-relation-level
+ class="mat-slide" formControlName="fetchLastLevelOnly"> {{ 'alias.last-level-relation' | translate }}
@@ -191,98 +220,113 @@ entityFilterFormGroup.get('type').value === aliasFilterType.edgeSearchQuery || entityFilterFormGroup.get('type').value === aliasFilterType.entityViewSearchQuery ? entityFilterFormGroup.get('type').value : ''"> - -
- - - -
-
- - -
-
- - alias.state-entity-parameter-name - - -
- - - +
+
+
alias.root-entity
+ + {{ 'alias.root-state-entity' | translate }} + + @if (filterFormGroup.get('rootStateEntity').value) { +
+ + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ + +
+
+ } @else { + + + }
-
-
-
- +
+
alias.query-options
+
+ + relation.direction + + + {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} + + + + + alias.max-relation-level + + +
+ + {{ 'alias.last-level-relation' | translate }} - -
-
-
- - relation.direction - - - {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} - - - - - alias.max-relation-level - - -
-
relation.relation-type
- - - - -
asset.asset-types
- - -
- -
device.device-types
- - -
- -
edge.edge-types
- - -
- -
entity-view.entity-view-types
- - +
+
+
relation.relation-filter
+ + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss index 686a55eb35..89e816eb35 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.scss @@ -15,25 +15,7 @@ */ :host { .tb-entity-filter { - #relationsQueryFilter { - padding-top: 20px; - - tb-entity-select { - min-height: 92px; - } - } - - .tb-root-state-entity-switch { - padding-left: 10px; - padding-bottom: 10px; - - .root-state-entity-switch { - margin: 0; - } - - .root-state-entity-label { - margin: 5px 0 5px 10px; - } - } + display: flex; + flex-direction: column; } } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index ac0296e2a5..31a3066edf 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -203,6 +203,8 @@ import { import { CalculatedFieldTestArgumentsComponent } from '@home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component'; +import { CheckConnectivityDialogComponent } from '@home/components/ai-model/check-connectivity-dialog.component'; +import { AIModelDialogComponent } from '@home/components/ai-model/ai-model-dialog.component'; @NgModule({ declarations: @@ -354,6 +356,8 @@ import { CalculatedFieldDebugDialogComponent, CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestArgumentsComponent, + CheckConnectivityDialogComponent, + AIModelDialogComponent, ], imports: [ CommonModule, @@ -499,6 +503,8 @@ import { CalculatedFieldDebugDialogComponent, CalculatedFieldScriptTestDialogComponent, CalculatedFieldTestArgumentsComponent, + CheckConnectivityDialogComponent, + AIModelDialogComponent, ], providers: [ WidgetComponentService, diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.scss b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.scss index 18c2498a64..b3e579787b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.scss @@ -31,21 +31,5 @@ } } } - .tb-entity-select { - @media screen and (min-width: 599px) { - display: flex; - flex-direction: row; - gap: 16px; - } - tb-entity-type-select { - flex: 1; - } - tb-entity-autocomplete { - flex: 1; - mat-form-field { - width: 100% !important; - } - } - } } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.ts index 0cf67f9344..f60b8b0f97 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.ts @@ -194,7 +194,7 @@ export class ArgumentsMapConfigComponent extends PageComponent implements Contro key: [property?.key, [Validators.required]], name: [ArgumentName[index], [Validators.required]], attributeScope: [property?.attributeScope ?? AttributeScope.SERVER_SCOPE, [Validators.required]], - defaultValue: [property?.defaultValue ? property?.defaultValue : null] + defaultValue: [property?.defaultValue ?? null] }); this.updateArgumentControlValidators(argumentControl); argumentControl.get('type').valueChanges.pipe( diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html index 37b0a2983c..0da1cf4023 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.html @@ -16,25 +16,38 @@ -->
- - {{ labelText }} - + + @if (labelText && !inlineField) { + {{ labelText }} + } +
- - - {{ requiredText }} - - - {{ minErrorText }} - - - {{ maxErrorText }} - + @if (inlineField) { + + warning + + } @else { + + + {{ hasError }} + + }
- - rule-node-config.units + + @if (!inlineField) { + rule-node-config.units + } @for (timeUnit of timeUnits; track timeUnit) { {{ timeUnitTranslations.get(timeUnit) | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts index b0d0a97641..e31d9abf9e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/time-unit-input.component.ts @@ -30,7 +30,7 @@ import { isDefinedAndNotNull, isNumeric } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { coerceBoolean, coerceNumber } from '@shared/decorators/coercion'; import { DAY, HOUR, MINUTE, SECOND } from '@shared/models/time/time.models'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; interface TimeUnitInputModel { time: number; @@ -79,6 +79,13 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, @Input() subscriptSizing: SubscriptSizing = 'fixed'; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + + @Input() + @coerceBoolean() + inlineField: boolean; + timeUnits = Object.values(TimeUnit).filter(item => item !== TimeUnit.MILLISECONDS) as TimeUnit[]; timeUnitTranslations = timeUnitTranslations; @@ -104,6 +111,16 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, } ngOnInit() { + if (this.maxTime) { + const maxTimeMs = this.maxTime * SECOND; + if (maxTimeMs < MINUTE) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.MINUTES && item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (maxTimeMs < HOUR) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.HOURS && item !== TimeUnit.DAYS); + } else if (maxTimeMs < DAY) { + this.timeUnits = this.timeUnits.filter(item => item !== TimeUnit.DAYS); + } + } if(this.required || this.maxTime) { const timeControl = this.timeInputForm.get('time'); const validators = [Validators.pattern(/^\d*$/)]; @@ -137,6 +154,16 @@ export class TimeUnitInputComponent implements ControlValueAccessor, Validator, }); } + get hasError(): string { + if (this.timeInputForm.get('time').hasError('required') && this.requiredText) { + return this.requiredText; + } else if (this.timeInputForm.get('time').hasError('min') && this.minErrorText) { + return this.minErrorText; + } else if (this.timeInputForm.get('time').hasError('max') && this.maxErrorText) { + return this.maxErrorText; + } + } + registerOnChange(fn: any) { this.propagateChange = fn; } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html new file mode 100644 index 0000000000..80519cea28 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -0,0 +1,135 @@ + +
+
+
+ {{ 'rule-node-config.ai.ai-model' | translate }} +
+
+ + +
+
+ +
+ + + + {{'rule-node-config.ai.prompt-settings' | translate}} + + +
+ + + + rule-node-config.ai.system-prompt + + + {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} + + + {{ 'rule-node-config.ai.system-prompt-blank' | translate }} + + + + rule-node-config.ai.user-prompt + + + {{ 'rule-node-config.ai.user-prompt-required' | translate }} + + + {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} + + + {{ 'rule-node-config.ai.user-prompt-blank' | translate }} + + +
+
+
+ +
+
+
+ {{ 'rule-node-config.ai.response-format' | translate }} +
+ + {{ 'rule-node-config.ai.response-text' | translate }} + {{ 'rule-node-config.ai.response-json' | translate }} + {{ 'rule-node-config.ai.response-json-schema' | translate }} + +
+ @if (aiConfigForm.get('responseFormat.type').value === responseFormat.JSON_SCHEMA) { + + + + } +
+ +
+ + + rule-node-config.ai.advanced-settings + + +
+
+
{{ 'rule-node-config.ai.timeout' | translate }}
+
+ + +
+
+
+ + {{ 'rule-node-config.ai.force-acknowledgement' | translate }} + +
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts new file mode 100644 index 0000000000..47313da81d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.ts @@ -0,0 +1,117 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; +import { EntityType } from '@shared/models/entity-type.models'; +import { MatDialog } from '@angular/material/dialog'; +import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component'; +import { AiModel, AiRuleNodeResponseFormatTypeOnlyText, ResponseFormat } from '@shared/models/ai-model.models'; +import { deepTrim } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-external-node-ai-config', + templateUrl: './ai-config.component.html', + styleUrls: [] +}) +export class AiConfigComponent extends RuleNodeConfigurationComponent { + + aiConfigForm: UntypedFormGroup; + + entityType = EntityType; + + responseFormat = ResponseFormat; + + disabledResponseFormatType: boolean; + + constructor(private fb: UntypedFormBuilder, + private translate: TranslateService, + private dialog: MatDialog) { + super(); + } + + protected configForm(): UntypedFormGroup { + return this.aiConfigForm; + } + + protected onConfigurationSet(configuration: RuleNodeConfiguration) { + this.aiConfigForm = this.fb.group({ + modelId: [configuration?.modelId ?? null, [Validators.required]], + systemPrompt: [configuration?.systemPrompt ?? '', [Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + userPrompt: [configuration?.userPrompt ?? '', [Validators.required, Validators.maxLength(10000), Validators.pattern(/.*\S.*/)]], + responseFormat: this.fb.group({ + type: [configuration?.responseFormat?.type ?? ResponseFormat.JSON, []], + schema: [configuration?.responseFormat?.schema ?? null, [Validators.required]], + }), + timeoutSeconds: [configuration?.timeoutSeconds ?? 60, []], + forceAck: [configuration?.forceAck ?? true, []] + }); + } + + protected validatorTriggers(): string[] { + return ['responseFormat.type']; + } + + protected updateValidators(emitEvent: boolean) { + if (this.aiConfigForm.get('responseFormat.type').value === ResponseFormat.JSON_SCHEMA) { + this.aiConfigForm.get('responseFormat.schema').enable({emitEvent: false}); + } else { + this.aiConfigForm.get('responseFormat.schema').disable({emitEvent: false}); + } + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + if (!this.aiConfigForm.get('systemPrompt').value) { + delete configuration.systemPrompt; + } + return deepTrim(configuration); + } + + onEntityChange($event: AiModel) { + if ($event) { + if (AiRuleNodeResponseFormatTypeOnlyText.includes($event.configuration.provider)) { + if (this.aiConfigForm.get('responseFormat.type').value !== ResponseFormat.TEXT) { + this.aiConfigForm.get('responseFormat.type').patchValue(ResponseFormat.TEXT, {emitEvent: true}); + } + this.disabledResponseFormatType = true; + } + } else { + this.disabledResponseFormatType = false; + } + } + + get getResponseFormatHint() { + return this.translate.instant(`rule-node-config.ai.response-format-hint-${this.aiConfigForm.get('responseFormat.type').value}`); + } + + createModelAi(formControl: string) { + this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd: true + } + }).afterClosed() + .subscribe((model) => { + if (model) { + this.aiConfigForm.get(formControl).patchValue(model.id); + this.aiConfigForm.get(formControl).markAsDirty(); + } + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts index d2ed9c4f26..955c555989 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/external-rule-node-config.module.ts @@ -32,6 +32,7 @@ import { HomeComponentsModule } from '@home/components/public-api'; import { CommonRuleNodeConfigModule } from '../common/common-rule-node-config.module'; import { SlackConfigComponent } from './slack-config.component'; import { LambdaConfigComponent } from './lambda-config.component'; +import { AiConfigComponent } from '@home/components/rule-node/external/ai-config.component'; @NgModule({ declarations: [ @@ -47,7 +48,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ], imports: [ CommonModule, @@ -68,7 +70,8 @@ import { LambdaConfigComponent } from './lambda-config.component'; SendEmailConfigComponent, AzureIotHubConfigComponent, SendSmsConfigComponent, - SlackConfigComponent + SlackConfigComponent, + AiConfigComponent ] }) export class ExternalRuleNodeConfigModule { diff --git a/ui-ngx/src/app/modules/home/components/vc/version-control.scss b/ui-ngx/src/app/modules/home/components/vc/version-control.scss index 35a0932f19..f221fc66ad 100644 --- a/ui-ngx/src/app/modules/home/components/vc/version-control.scss +++ b/ui-ngx/src/app/modules/home/components/vc/version-control.scss @@ -16,9 +16,11 @@ :host { .vc-result-message { max-width: 65vw; + max-height: 65vh; padding: 0 8px; text-align: center; word-wrap: break-word; + overflow: auto; &:first-child { padding-top: 48px; } diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html index 1f48728166..d56fb4aaa7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html @@ -15,8 +15,8 @@ limitations under the License. --> -
- +
+ widget-config.datasource-type @@ -44,18 +44,20 @@ + class="flex-1" appearance="outline"> @@ -64,6 +66,7 @@
div.tb-single-switch-title-panel { position: absolute; - top: 12px; - left: 12px; - right: 12px; + top: 0; + left: 0; + right: 0; z-index: 2; } .tb-single-switch-content { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html index 3d2e69e210..3b2c14c539 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html @@ -43,7 +43,7 @@
-
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss index 002ccbeeb3..f0e09589ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.scss @@ -19,25 +19,66 @@ padding: 8px; background-color: #fff; + .css-panel, .html-panel{ + border-color: #c0c0c0; + border-width: 0 1px 1px 1px; + border-style: solid; + } + + .tb-js-func-toolbar{ + padding: 0 3px; + } + + .tb-js-func { + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 0; + } + } + } + .tb-fullscreen-panel { .tb-custom-action-editor-container { + height: 100%; + } + + .css-panel, .html-panel{ + border: none; + } + + .left-panel, .right-panel{ height: calc(100% - 40px); } .right-panel { - padding-top: 8px; - padding-left: 3px; + padding: 8px 0 0; } - tb-js-func .tb-js-func-panel { - box-sizing: border-box; + .tb-js-func { + .tb-js-func-panel { + box-sizing: border-box; + } + &.fill-height { + .tb-js-func-toolbar{ + padding: 0 5px; + } + + &.tb-hide-brackets { + .tb-js-func-panel { + border: none; + border-top: 1px solid #c0c0c0; + } + } + } } .mat-mdc-tab-group { .mat-mdc-tab-body-wrapper { height: 100%; + .mat-mdc-tab-body { height: 100%; + & > div { height: 100%; } @@ -70,7 +111,7 @@ .tb-split.tb-split-horizontal, .gutter.gutter-horizontal { float: left; - height: 100%; + height: calc(100% - 40px); } .tb-action-expand-button { @@ -81,20 +122,7 @@ &.tb-fullscreen-editor { position: relative; right: 0; - /* .mat-mdc-button { - .mat-icon { - margin-right: 5px; - } - } */ } - - /* .mat-mdc-button { - min-width: 36px; - padding: 0; - .mat-icon { - margin-right: 0; - } - } */ } .tb-custom-action-editor { @@ -105,3 +133,4 @@ } + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html index f2a2ee4b9c..882e27b01b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html @@ -74,8 +74,7 @@
- {{ translate.get('entity.no-alias-matching', - {alias: truncate.transform(searchText, true, 6, '...')}) | async }} + {{ 'entity.no-alias-matching' | translate : {alias: (searchText | truncate: true: 6: '...')} }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts index 2d170f55e3..1af25bd01e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, ElementRef, forwardRef, Input, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { Component, DestroyRef, ElementRef, forwardRef, Input, OnInit, SkipSelf, ViewChild } from '@angular/core'; import { ControlValueAccessor, FormBuilder, @@ -26,18 +26,17 @@ import { } from '@angular/forms'; import { Observable, of } from 'rxjs'; import { map, mergeMap, share, tap } from 'rxjs/operators'; -import { TranslateService } from '@ngx-translate/core'; import { EntityType } from '@shared/models/entity-type.models'; import { EntityService } from '@core/http/entity.service'; import { coerceBoolean } from '@shared/decorators/coercion'; import { EntityAlias } from '@shared/models/alias.models'; import { IAliasController } from '@core/api/widget-api.models'; -import { TruncatePipe } from '@shared/pipe/truncate.pipe'; -import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { MatAutocomplete } from '@angular/material/autocomplete'; import { EntityAliasSelectCallbacks } from './entity-alias-select.component.models'; import { ENTER } from '@angular/cdk/keycodes'; import { ErrorStateMatcher } from '@angular/material/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-entity-alias-select', @@ -47,11 +46,7 @@ import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form- provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => EntityAliasSelectComponent), multi: true - }/*, - { - provide: ErrorStateMatcher, - useExisting: EntityAliasSelectComponent - }*/] + }] }) export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, ErrorStateMatcher { @@ -72,7 +67,6 @@ export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, showLabel: boolean; @ViewChild('entityAliasAutocomplete') entityAliasAutocomplete: MatAutocomplete; - @ViewChild('autocomplete', { read: MatAutocompleteTrigger }) autoCompleteTrigger: MatAutocompleteTrigger; @Input() @coerceBoolean() @@ -93,21 +87,18 @@ export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, @ViewChild('entityAliasInput', {static: true}) entityAliasInput: ElementRef; - entityAliasList: Array = []; - filteredEntityAliases: Observable>; searchText = ''; private dirty = false; - + private entityAliasList: Array = []; private propagateChange = (_v: any) => { }; constructor(@SkipSelf() private errorStateMatcher: ErrorStateMatcher, private entityService: EntityService, - public translate: TranslateService, - public truncate: TruncatePipe, - private fb: FormBuilder) { + private fb: FormBuilder, + private destroyRef: DestroyRef) { this.selectEntityAliasFormGroup = this.fb.group({ entityAlias: [null] }); @@ -121,15 +112,7 @@ export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, } ngOnInit() { - const entityAliases = this.aliasController.getEntityAliases(); - for (const aliasId of Object.keys(entityAliases)) { - if (this.allowedEntityTypes && this.allowedEntityTypes.length) { - if (!this.entityService.filterAliasByEntityTypes(entityAliases[aliasId], this.allowedEntityTypes)) { - continue; - } - } - this.entityAliasList.push(entityAliases[aliasId]); - } + this.loadEntityAliases(); this.filteredEntityAliases = this.selectEntityAliasFormGroup.get('entityAlias').valueChanges .pipe( @@ -149,6 +132,12 @@ export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, mergeMap(name => this.fetchEntityAliases(name) ), share() ); + + this.aliasController.entityAliasesChanged.pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => { + this.loadEntityAliases(); + }); } isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { @@ -271,4 +260,18 @@ export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, ); } } + + private loadEntityAliases(): void { + this.entityAliasList = []; + const entityAliases = this.aliasController.getEntityAliases(); + for (const aliasId of Object.keys(entityAliases)) { + if (this.allowedEntityTypes?.length) { + if (!this.entityService.filterAliasByEntityTypes(entityAliases[aliasId], this.allowedEntityTypes)) { + continue; + } + } + this.entityAliasList.push(entityAliases[aliasId]); + } + this.dirty = true; + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html index ff1bcb06a4..7b11058228 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html @@ -66,8 +66,7 @@
- {{ translate.get('filter.no-filter-matching', - {filter: truncate.transform(searchText, true, 6, '...')}) | async }} + {{ 'filter.no-filter-matching' | translate : {filter: (searchText | truncate: true: 6: '...')} }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts index 6484f2ca18..abc6f77e31 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts @@ -14,31 +14,27 @@ /// limitations under the License. /// -import { AfterViewInit, Component, ElementRef, forwardRef, Input, OnInit, SkipSelf, ViewChild } from '@angular/core'; +import { Component, DestroyRef, ElementRef, forwardRef, Input, OnInit, SkipSelf, ViewChild } from '@angular/core'; import { ControlValueAccessor, - UntypedFormBuilder, - UntypedFormControl, - UntypedFormGroup, FormGroupDirective, NG_VALUE_ACCESSOR, - NgForm + NgForm, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup } from '@angular/forms'; import { Observable, of } from 'rxjs'; import { map, mergeMap, share, tap } from 'rxjs/operators'; -import { Store } from '@ngrx/store'; -import { AppState } from '@app/core/core.state'; -import { TranslateService } from '@ngx-translate/core'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { IAliasController } from '@core/api/widget-api.models'; -import { TruncatePipe } from '@shared/pipe/truncate.pipe'; -import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { MatAutocomplete } from '@angular/material/autocomplete'; import { ENTER } from '@angular/cdk/keycodes'; import { ErrorStateMatcher } from '@angular/material/core'; import { FilterSelectCallbacks } from './filter-select.component.models'; import { Filter } from '@shared/models/query/query.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ selector: 'tb-filter-select', @@ -54,7 +50,7 @@ import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form- useExisting: FilterSelectComponent }] }) -export class FilterSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, ErrorStateMatcher { +export class FilterSelectComponent implements ControlValueAccessor, OnInit, ErrorStateMatcher { selectFilterFormGroup: UntypedFormGroup; @@ -81,40 +77,27 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte subscriptSizing: SubscriptSizing = 'fixed'; @ViewChild('filterAutocomplete') filterAutocomplete: MatAutocomplete; - @ViewChild('autocomplete', { read: MatAutocompleteTrigger }) autoCompleteTrigger: MatAutocompleteTrigger; - - private requiredValue: boolean; - get tbRequired(): boolean { - return this.requiredValue; - } @Input() - set tbRequired(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + @coerceBoolean() + tbRequired: boolean; @Input() disabled: boolean; @ViewChild('filterInput', {static: true}) filterInput: ElementRef; - filterList: Array = []; - filteredFilters: Observable>; searchText = ''; private dirty = false; + private filterList: Array = []; + private propagateChange = (_v: any) => { }; - private creatingFilter = false; - - private propagateChange = (v: any) => { }; - - constructor(private store: Store, - @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public translate: TranslateService, - public truncate: TruncatePipe, - private fb: UntypedFormBuilder) { + constructor(@SkipSelf() private errorStateMatcher: ErrorStateMatcher, + private fb: UntypedFormBuilder, + private destroyRef: DestroyRef) { this.selectFilterFormGroup = this.fb.group({ filter: [null] }); @@ -124,19 +107,16 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte this.propagateChange = fn; } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } ngOnInit() { - const filters = this.aliasController.getFilters(); - for (const filterId of Object.keys(filters)) { - this.filterList.push(filters[filterId]); - } + this.loadFilters(); this.filteredFilters = this.selectFilterFormGroup.get('filter').valueChanges .pipe( tap(value => { - let modelValue; + let modelValue: Filter; if (typeof value === 'string' || !value) { modelValue = null; } else { @@ -151,6 +131,12 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte mergeMap(name => this.fetchFilters(name) ), share() ); + + this.aliasController.filtersChanged.pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => { + this.loadFilters(); + }); } isErrorState(control: UntypedFormControl | null, form: FormGroupDirective | NgForm | null): boolean { @@ -159,8 +145,6 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte return originalErrorState || customErrorState; } - ngAfterViewInit(): void {} - setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { @@ -227,7 +211,7 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte } textIsNotEmpty(text: string): boolean { - return (text && text != null && text.length > 0) ? true : false; + return text?.length > 0; } filterEnter($event: KeyboardEvent) { @@ -242,7 +226,6 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte createFilter($event: Event, filter: string, focusOnCancel = true) { $event.preventDefault(); $event.stopPropagation(); - this.creatingFilter = true; if (this.callbacks && this.callbacks.createFilter) { this.callbacks.createFilter(filter).subscribe((newFilter) => { if (!newFilter) { @@ -253,7 +236,6 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte }, 0); } } else { - this.filterList.push(newFilter); this.modelValue = newFilter.id; this.selectFilterFormGroup.get('filter').patchValue(newFilter, {emitEvent: true}); this.propagateChange(this.modelValue); @@ -262,4 +244,13 @@ export class FilterSelectComponent implements ControlValueAccessor, OnInit, Afte ); } } + + private loadFilters(): void { + this.filterList = []; + const filters = this.aliasController.getFilters(); + for (const filterId of Object.keys(filters)) { + this.filterList.push(filters[filterId]); + } + this.dirty = true; + } } diff --git a/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts b/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts index 44a687ef35..168c56cef7 100644 --- a/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts +++ b/ui-ngx/src/app/modules/home/models/datasource/entity-datasource.ts @@ -15,7 +15,7 @@ /// import { PageLink } from '@shared/models/page/page-link'; -import { BehaviorSubject, Observable, of, ReplaySubject } from 'rxjs'; +import { BehaviorSubject, Observable, of, ReplaySubject, Subscription } from 'rxjs'; import { emptyPageData, PageData } from '@shared/models/page/page-data'; import { BaseData, HasId } from '@shared/models/base-data'; import { CollectionViewer, DataSource, SelectionModel } from '@angular/cdk/collections'; @@ -28,6 +28,7 @@ export class EntitiesDataSource, P extends PageLink = private entitiesSubject = new BehaviorSubject([]); private pageDataSubject = new BehaviorSubject>(emptyPageData()); + private currentLoadSubscription: Subscription = null; public pageData$ = this.pageDataSubject.asObservable(); @@ -58,9 +59,12 @@ export class EntitiesDataSource, P extends PageLink = } loadEntities(pageLink: P): Observable> { + if (this.currentLoadSubscription) { + this.currentLoadSubscription.unsubscribe(); + } this.dataLoading = true; const result = new ReplaySubject>(); - this.fetchFunction(pageLink).pipe( + this.currentLoadSubscription = this.fetchFunction(pageLink).pipe( tap(() => { this.selection.clear(); }), diff --git a/ui-ngx/src/app/modules/home/models/services.map.ts b/ui-ngx/src/app/modules/home/models/services.map.ts index e4217b726b..ebccba900c 100644 --- a/ui-ngx/src/app/modules/home/models/services.map.ts +++ b/ui-ngx/src/app/modules/home/models/services.map.ts @@ -54,6 +54,7 @@ import { EventService } from '@core/http/event.service'; import { UnitService } from '@core/services/unit.service'; import { AuditLogService } from '@core/http/audit-log.service'; import { TrendzSettingsService } from '@core/http/trendz-settings.service'; +import { AiModelService } from '@core/http/ai-model.service'; export const ServicesMap = new Map>( [ @@ -95,6 +96,7 @@ export const ServicesMap = new Map>( ['eventService', EventService], ['unitService', UnitService], ['auditLogService', AuditLogService], - ['trendzSettingsService', TrendzSettingsService] + ['trendzSettingsService', TrendzSettingsService], + ['aiModelService', AiModelService] ] ); diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index 2836224a9a..2831197a4d 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -47,6 +47,7 @@ import { MenuId } from '@core/services/menu.models'; import { catchError } from 'rxjs/operators'; import { JsLibraryTableConfigResolver } from '@home/pages/admin/resource/js-library-table-config.resolver'; import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.component'; +import { aiModelRoutes } from '@home/pages/ai-model/ai-model-routing.module'; export const scadaSymbolResolver: ResolveFn = (route: ActivatedRouteSnapshot, @@ -362,6 +363,7 @@ const routes: Routes = [ } } }, + ...aiModelRoutes, { path: 'security-settings', redirectTo: '/security-settings/general' diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index a5f18122fd..10721ade5a 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -38,6 +38,7 @@ import { JsLibraryTableHeaderComponent } from '@home/pages/admin/resource/js-lib import { JsResourceComponent } from '@home/pages/admin/resource/js-resource.component'; import { NgxFlowModule } from '@flowjs/ngx-flow'; import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.component'; +import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; @NgModule({ declarations: @@ -50,6 +51,7 @@ import { TrendzSettingsComponent } from '@home/pages/admin/trendz-settings.compo HomeSettingsComponent, ResourcesLibraryComponent, ResourceTabsComponent, + ResourceLibraryTabsComponent, ResourcesTableHeaderComponent, JsResourceComponent, JsLibraryTableHeaderComponent, diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html index 4f37d8f480..9873ca29f7 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html @@ -34,7 +34,7 @@
admin.oauth2.redirect-url-template - + + + + diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.ts new file mode 100644 index 0000000000..a85bf48db3 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.ts @@ -0,0 +1,36 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { EntityTabsComponent } from '@home/components/entity/entity-tabs.component'; +import { Resource } from '@shared/models/resource.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; + +@Component({ + selector: 'tb-resource-library-tabs', + templateUrl: './resource-library-tabs.component.html', + styleUrls: [] +}) +export class ResourceLibraryTabsComponent extends EntityTabsComponent { + + readonly NULL_UUID = NULL_UUID; + + constructor(protected store: Store) { + super(store); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts index 91327b7465..f39ed9ff7b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-library-table-config.resolve.ts @@ -37,6 +37,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { map } from 'rxjs/operators'; import { ResourcesTableHeaderComponent } from '@home/pages/admin/resource/resources-table-header.component'; +import { ResourceLibraryTabsComponent } from '@home/pages/admin/resource/resource-library-tabs.component'; @Injectable() export class ResourcesLibraryTableConfigResolver { @@ -55,6 +56,7 @@ export class ResourcesLibraryTableConfigResolver { this.config.entityTranslations = entityTypeTranslations.get(EntityType.TB_RESOURCE); this.config.entityResources = entityTypeResources.get(EntityType.TB_RESOURCE); this.config.headerComponent = ResourcesTableHeaderComponent; + this.config.entityTabsComponent = ResourceLibraryTabsComponent; this.config.entityTitle = (resource) => resource ? resource.title : ''; diff --git a/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts index 16ae6bf1da..f477b6fdaf 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts @@ -21,6 +21,9 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { TrendzSettingsService } from '@core/http/trendz-settings.service'; import { TrendzSettings } from '@shared/models/trendz-settings.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from "@ngrx/store"; +import { AppState } from "@core/core.state"; +import { ActionAuthUpdateTrendzSettings } from "@core/auth/auth.actions"; @Component({ selector: 'tb-trendz-settings', @@ -31,7 +34,8 @@ export class TrendzSettingsComponent extends PageComponent implements OnInit, Ha trendzSettingsForm: FormGroup; - constructor(private fb: FormBuilder, + constructor(protected store: Store, + private fb: FormBuilder, private trendzSettingsService: TrendzSettingsService, private destroyRef: DestroyRef) { super(); @@ -93,6 +97,7 @@ export class TrendzSettingsComponent extends PageComponent implements OnInit, Ha this.trendzSettingsService.saveTrendzSettings(trendzSettings) .subscribe(() => { this.setTrendzSettings(trendzSettings); + this.store.dispatch(new ActionAuthUpdateTrendzSettings(trendzSettings)) }) } } diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts new file mode 100644 index 0000000000..82fff06270 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-routing.module.ts @@ -0,0 +1,49 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + + +import { RouterModule, Routes } from '@angular/router'; +import { EntitiesTableComponent } from '@home/components/entity/entities-table.component'; +import { Authority } from '@shared/models/authority.enum'; +import { MenuId } from '@core/services/menu.models'; +import { AiModelsTableConfigResolver } from '@home/pages/ai-model/ai-model-table-config.resolve'; +import { NgModule } from '@angular/core'; + +export const aiModelRoutes: Routes = [ + { + path: 'ai-models', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'ai-models.ai-models', + breadcrumb: { + menuId: MenuId.ai_models + } + }, + resolve: { + entitiesTableConfig: AiModelsTableConfigResolver + } + } +]; + +@NgModule({ + providers: [ + AiModelsTableConfigResolver + ], + imports: [RouterModule.forChild(aiModelRoutes)], + exports: [RouterModule], +}) +export class AiModelRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts new file mode 100644 index 0000000000..21a5f50a2f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-config.resolve.ts @@ -0,0 +1,118 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { + CellActionDescriptor, + DateEntityTableColumn, + EntityTableColumn, + EntityTableConfig +} from '@home/models/entity/entities-table-config.models'; +import { ActivatedRouteSnapshot } from '@angular/router'; +import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { Direction } from '@shared/models/page/sort-order'; +import { DatePipe } from '@angular/common'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { Observable } from 'rxjs'; +import { AiModel, AiProviderTranslations } from '@shared/models/ai-model.models'; +import { AiModelService } from '@core/http/ai-model.service'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; +import { AIModelDialogComponent, AIModelDialogData } from '@home/components/ai-model/ai-model-dialog.component'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class AiModelsTableConfigResolver { + + private readonly config: EntityTableConfig = new EntityTableConfig(); + + constructor( + private datePipe: DatePipe, + private aiModelService: AiModelService, + private translate : TranslateService, + private dialog: MatDialog + ) { + this.config.selectionEnabled = true; + this.config.entityType = EntityType.AI_MODEL; + this.config.addAsTextButton = true; + this.config.rowPointer = true; + this.config.detailsPanelEnabled = false; + this.config.entityTranslations = entityTypeTranslations.get(EntityType.AI_MODEL); + this.config.entityResources = entityTypeResources.get(EntityType.AI_MODEL); + + this.config.headerComponent = AiModelTableHeaderComponent; + this.config.addDialogStyle = {width: '850px', maxHeight: '100vh'}; + this.config.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; + + this.config.addEntity = () => this.addModel(null, true); + + this.config.columns.push( + new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '170px'), + new EntityTableColumn('name', 'ai-models.name', '33%'), + new EntityTableColumn('provider', 'ai-models.provider', '33%', + entity => this.translate.instant(AiProviderTranslations.get(entity.configuration.provider)) + ), + new EntityTableColumn('modelId', 'ai-models.model', '33%', entity => entity.configuration.modelId) + ) + + this.config.deleteEntityTitle = model => this.translate.instant('ai-models.delete-model-title', {modelName: model.name}); + this.config.deleteEntityContent = () => this.translate.instant('ai-models.delete-model-text'); + this.config.deleteEntitiesTitle = count => this.translate.instant('ai-models.delete-models-title', {count}); + this.config.deleteEntitiesContent = () => this.translate.instant('ai-models.delete-models-text'); + + this.config.deleteEntity = id => this.aiModelService.deleteAiModel(id.id); + + this.config.entitiesFetchFunction = pageLink => this.aiModelService.getAiModels(pageLink); + + this.config.cellActionDescriptors = this.configureCellActions(); + + this.config.handleRowClick = ($event, model) => { + this.editModel($event, model); + return true; + }; + } + + resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { + return this.config; + } + + private configureCellActions(): Array> { + return [ + { + name: this.translate.instant('action.edit'), + icon: 'edit', + isEnabled: () => true, + onAction: ($event, entity) => this.editModel($event, entity) + } + ]; + } + + private editModel($event, AIModel: AiModel): void { + $event?.stopPropagation(); + this.addModel(AIModel, false).subscribe(res => res ? this.config.updateData() : null); + } + + private addModel(AIModel: AiModel, isAdd = false): Observable { + return this.dialog.open(AIModelDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + isAdd, + AIModel + } + }).afterClosed(); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html new file mode 100644 index 0000000000..43f1dbf0db --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.html @@ -0,0 +1,21 @@ + +
+
ai-models.ai-models
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts new file mode 100644 index 0000000000..48889dc877 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model-table-header.component.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { EntityTableHeaderComponent } from '@home/components/entity/entity-table-header.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { AiModel } from '@shared/models/ai-model.models'; + +@Component({ + selector: 'tb-ai-model-table-header', + templateUrl: './ai-model-table-header.component.html', + styleUrls: [] +}) +export class AiModelTableHeaderComponent extends EntityTableHeaderComponent { + + constructor(protected store: Store) { + super(store); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts new file mode 100644 index 0000000000..bd20612e8a --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ai-model/ai-model.module.ts @@ -0,0 +1,33 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { AiModelRoutingModule } from '@home/pages/ai-model/ai-model-routing.module'; +import { AiModelTableHeaderComponent } from '@home/pages/ai-model/ai-model-table-header.component'; + +@NgModule({ + declarations: [ + AiModelTableHeaderComponent + ], + imports: [ + CommonModule, + SharedModule, + AiModelRoutingModule + ] +}) +export class AiModelModule { } diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 39c442cc49..5bb3954cae 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -46,6 +46,7 @@ import { AccountModule } from '@home/pages/account/account.module'; import { ScadaSymbolModule } from '@home/pages/scada-symbol/scada-symbol.module'; import { GatewaysModule } from '@home/pages/gateways/gateways.module'; import { MobileModule } from '@home/pages/mobile/mobile.module'; +import { AiModelModule } from '@home/pages/ai-model/ai-model.module'; @NgModule({ exports: [ @@ -78,7 +79,8 @@ import { MobileModule } from '@home/pages/mobile/mobile.module'; UserModule, VcModule, AccountModule, - ScadaSymbolModule + ScadaSymbolModule, + AiModelModule, ] }) export class HomePagesModule { } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts index 0a0732362e..6a05558905 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts @@ -83,6 +83,7 @@ export class MobileAppTableConfigResolver { onAction: (_$event, entity) => entity.pkgName, type: CellActionDescriptorType.COPY_BUTTON }), + new EntityTableColumn('title', 'mobile.mobile-package-title', '20%'), new EntityTableColumn('appSecret', 'mobile.application-secret', '15%', (entity) => this.truncatePipe.transform(entity.appSecret, true, 10, '…'), () => ({}), true, () => ({}), () => undefined, false, diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html index daad184b9e..bbafb83e52 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html @@ -15,7 +15,7 @@ limitations under the License. --> -
+
mobile.mobile-package @@ -38,6 +38,14 @@ {{ 'mobile.mobile-package-pattern' | translate }} + + mobile.mobile-package-title + + + + {{ 'mobile.mobile-package-title-max-length' | translate }} + + mobile.platform-type diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss index 1925568b81..2462c55555 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.scss @@ -14,5 +14,5 @@ * limitations under the License. */ :host { - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-disabled-trailing-icon-color: rgba(0, 0, 0, 0.56); } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts index eceda5ac9a..141c274422 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts @@ -63,9 +63,10 @@ export class MobileAppComponent extends EntityComponent { buildForm(entity: MobileApp): FormGroup { const form = this.fb.group({ - pkgName: [entity?.pkgName ? entity.pkgName : '', [Validators.required, Validators.maxLength(255), + pkgName: [entity?.pkgName ?? '', [Validators.required, Validators.maxLength(255), Validators.pattern(/^[a-zA-Z][a-zA-Z\d_]*(?:\.[a-zA-Z][a-zA-Z\d_]*)+$/)]], - platformType: [entity?.platformType ? entity.platformType : PlatformType.ANDROID], + title: [entity?.title ?? '', [Validators.maxLength(255)]], + platformType: [entity?.platformType ?? PlatformType.ANDROID], appSecret: [entity?.appSecret ? entity.appSecret : btoa(randomAlphanumeric(64)), [Validators.required, this.base64Format]], status: [entity?.status ? entity.status : MobileAppStatus.DRAFT], versionInfo: this.fb.group({ diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html index 3c11be8930..afaa0da3c1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.html @@ -43,23 +43,14 @@ [data]=createMarkDownCommand(gitRepositoryLink)>
-
mobile.configuration-step.configure-api-title
-
mobile.configuration-step.configure-api-text
- -
mobile.configuration-step.configure-api-hint
- -
-
-
mobile.configuration-step.configure-package-title
-
mobile.configuration-step.configure-package-text
-
mobile.configuration-step.configure-package-text-install
- -
mobile.configuration-step.configure-package-run-commands
- +
mobile.configuration-step.configure-app-settings-title
+
+
mobile.configuration-step.configure-app-settings-text
+ +
mobile.configuration-step.run-app-title
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts index 9168f14245..5644620b27 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts @@ -21,12 +21,15 @@ import { AppState } from '@core/core.state'; import { Router } from '@angular/router'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { ActionPreferencesPutUserSettings } from '@core/auth/auth.actions'; -import { MobileApp } from '@shared/models/mobile-app.models'; +import { MobileApp, MobileAppBundleInfo } from '@shared/models/mobile-app.models'; +import { ImportExportService } from '@shared/import-export/import-export.service'; +import { isNotEmptyStr } from '@core/utils'; export interface MobileAppConfigurationDialogData { afterAdd: boolean; androidApp: MobileApp; iosApp: MobileApp; + bundle: MobileAppBundleInfo; } @Component({ @@ -36,53 +39,22 @@ export interface MobileAppConfigurationDialogData { }) export class MobileAppConfigurationDialogComponent extends DialogComponent { - notShowAgain = false; - setApplication = false; + private fileName = 'configs'; + notShowAgain = false; showDontShowAgain: boolean; gitRepositoryLink = 'git clone -b master https://github.com/thingsboard/flutter_thingsboard_app.git'; - pathToConstants = 'lib/constants/app_constants.dart'; - flutterRunCommand = 'flutter run'; - flutterInstallRenameCommand = 'flutter pub global activate rename'; - - configureApi: string; - - renameCommands: string[] = []; + flutterRunCommand = `flutter run --dart-define-from-file ${this.fileName}.json`; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) private data: MobileAppConfigurationDialogData, protected dialogRef: MatDialogRef, + private importExportService: ImportExportService, ) { super(store, router, dialogRef); - this.showDontShowAgain = this.data.afterAdd; - - this.setApplication = !!this.data.androidApp || !!this.data.iosApp; - - this.configureApi = `static const thingsBoardApiEndpoint = '${window.location.origin}';`; - if (this.setApplication) { - this.configureApi += '\n'; - if (!!this.data.androidApp) { - this.configureApi += `\nstatic const thingsboardAndroidAppSecret = '${this.data.androidApp.appSecret}';`; - } - if (!!this.data.iosApp) { - this.configureApi += `\nstatic const thingsboardIOSAppSecret = '${this.data.iosApp.appSecret}';`; - } - } - if (this.setApplication) { - if (this.data.androidApp?.pkgName === this.data.iosApp?.pkgName) { - this.renameCommands.push(`rename setBundleId --targets android, ios --value "${this.data.androidApp.pkgName}"`); - } else { - if (!!this.data.androidApp) { - this.renameCommands.push(`rename setBundleId --targets android --value "${this.data.androidApp.pkgName}"`); - } - if (!!this.data.iosApp) { - this.renameCommands.push(`rename setBundleId --targets ios --value "${this.data.iosApp.pkgName}"`); - } - } - } } close(): void { @@ -94,14 +66,28 @@ export class MobileAppConfigurationDialogComponent extends DialogComponent = []; - commands.forEach(command => formatCommands.push(this.createMarkDownSingleCommand(command))); - return formatCommands.join(`\n
\n\n`); - } else { - return this.createMarkDownSingleCommand(commands); + createMarkDownCommand(commands: string): string { + return this.createMarkDownSingleCommand(commands); + } + + downloadSettings(): void { + const settings: any = { + thingsboardApiEndpoint: window.location.origin, + appLinksUrlHost: window.location.host, + appLinksUrlScheme: window.location.protocol.slice(0, -1), + }; + if (!!this.data.androidApp) { + settings.androidApplicationId = this.data.androidApp.pkgName; + settings.androidApplicationName = isNotEmptyStr(this.data.androidApp.title) ? this.data.androidApp.title : this.data.bundle.title; + settings.thingsboardOAuth2CallbackUrlScheme = this.data.androidApp.pkgName + '.auth'; + settings.thingsboardAndroidAppSecret = this.data.androidApp.appSecret; + } + if (!!this.data.iosApp) { + settings.iosApplicationId = this.data.iosApp.pkgName; + settings.iosApplicationName = isNotEmptyStr(this.data.iosApp.title) ? this.data.iosApp.title : this.data.bundle.title; + settings.thingsboardIosAppSecret = this.data.iosApp.appSecret; } + this.importExportService.exportJson(settings, this.fileName); } private createMarkDownSingleCommand(command: string): string { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts index 6a0dc25d70..0b8d1efc21 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-table-config.resolve.ts @@ -182,7 +182,8 @@ export class MobileBundleTableConfigResolver { data: { afterAdd, androidApp: data.androidApp, - iosApp: data.iosApp + iosApp: data.iosApp, + bundle: entity } }).afterClosed() .subscribe(); diff --git a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html index 951aac77a2..f88244add1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html @@ -39,10 +39,9 @@
{{ 'mobile.bundle' | translate }}
diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts index cc3ca4046b..0dae778d03 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-table-config.resolve.ts @@ -36,6 +36,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { FileSizePipe } from '@shared/pipe/file-size.pipe'; +import { OtaUpdateTabsComponent } from '@home/pages/ota-update/ota-update-tabs.component'; @Injectable() export class OtaUpdateTableConfigResolve { @@ -50,6 +51,7 @@ export class OtaUpdateTableConfigResolve { private fileSize: FileSizePipe) { this.config.entityType = EntityType.OTA_PACKAGE; this.config.entityComponent = OtaUpdateComponent; + this.config.entityTabsComponent = OtaUpdateTabsComponent; this.config.entityTranslations = entityTypeTranslations.get(EntityType.OTA_PACKAGE); this.config.entityResources = entityTypeResources.get(EntityType.OTA_PACKAGE); diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.html new file mode 100644 index 0000000000..a8cdae4256 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.html @@ -0,0 +1,23 @@ + + + + diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.ts new file mode 100644 index 0000000000..44d17f3c11 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update-tabs.component.ts @@ -0,0 +1,40 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; +import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { NULL_UUID } from '@shared/models/id/has-uuid'; +import { OtaPackage } from '@shared/models/ota-package.models'; + +@Component({ + selector: 'tb-ota-update-tabs', + templateUrl: './ota-update-tabs.component.html', + styleUrls: [] +}) +export class OtaUpdateTabsComponent extends EntityTabsComponent { + + constructor(protected store: Store) { + super(store); + } + + isTenantOtaUpdate() { + return this.entity && this.entity.tenantId.id !== NULL_UUID; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html index e028a2714a..ee8e34a4a2 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.component.html @@ -63,7 +63,7 @@
-
+
ota-update.title diff --git a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts index 139591f14f..fe24da31b2 100644 --- a/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts +++ b/ui-ngx/src/app/modules/home/pages/ota-update/ota-update.module.ts @@ -20,10 +20,12 @@ import { SharedModule } from '@shared/shared.module'; import { HomeComponentsModule } from '@home/components/home-components.module'; import { OtaUpdateRoutingModule } from '@home/pages/ota-update/ota-update-routing.module'; import { OtaUpdateComponent } from '@home/pages/ota-update/ota-update.component'; +import { OtaUpdateTabsComponent } from '@home/pages/ota-update/ota-update-tabs.component'; @NgModule({ declarations: [ - OtaUpdateComponent + OtaUpdateComponent, + OtaUpdateTabsComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index ac8054283a..8cb785c6f1 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -14,16 +14,7 @@ /// limitations under the License. /// -import { - Component, - ElementRef, - EventEmitter, - forwardRef, - Input, - OnInit, - Output, - ViewChild -} from '@angular/core'; +import { Component, ElementRef, EventEmitter, forwardRef, Input, OnInit, Output, ViewChild } from '@angular/core'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { firstValueFrom, merge, Observable, of, Subject } from 'rxjs'; @@ -300,6 +291,12 @@ export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit this.entityRequiredText = 'notification.notification-recipient-required'; this.notFoundEntities = 'notification.no-recipients-text'; break; + case EntityType.AI_MODEL: + this.entityText = 'ai-models.ai-model'; + this.noEntitiesMatchingText = 'ai-models.no-model-matching'; + this.entityRequiredText = 'ai-models.model-required'; + this.notFoundEntities = 'ai-models.no-model-text'; + break; case AliasEntityType.CURRENT_CUSTOMER: this.entityText = 'customer.default-customer'; this.noEntitiesMatchingText = 'customer.no-customers-matching'; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index e1b951b40b..9510f2a952 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -21,7 +21,7 @@ [class.tb-chips]="inlineField" [class.flex]="inlineField" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> - {{ labelText }} {{ labelText }} + {{ labelText }} -
+
diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index a85589fae4..01b1f03388 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -25,6 +25,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ selector: 'tb-entity-select', @@ -58,6 +59,9 @@ export class EntitySelectComponent implements ControlValueAccessor, OnInit, Afte @Input() additionEntityTypes: {[entityType in string]: string} = {}; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + displayEntityTypeSelect: boolean; AliasEntityType = AliasEntityType; diff --git a/ui-ngx/src/app/shared/components/help-popup.component.html b/ui-ngx/src/app/shared/components/help-popup.component.html index c24b5234b9..8bd3dbcfa9 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.html +++ b/ui-ngx/src/app/shared/components/help-popup.component.html @@ -17,9 +17,9 @@ -->
-
+
+ - + [panelWidth]="panelWidth"> diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts index 2f1ea5db14..70b1115b5b 100644 --- a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts @@ -76,6 +76,9 @@ export class StringAutocompleteComponent implements ControlValueAccessor, OnInit @Input() label: string; + @Input() + panelWidth: string = 'fit-content'; + @Input() tooltipClass = 'tb-error-tooltip'; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 2ad60f0139..94960cb838 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -1188,7 +1188,7 @@ export class ImportExportService { this.exportJson(data, filename); } - private exportJson(data: any, filename: string) { + public exportJson(data: any, filename: string) { if (isObject(data)) { data = JSON.stringify(data, null, 2); } diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts new file mode 100644 index 0000000000..f3161263b7 --- /dev/null +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -0,0 +1,230 @@ +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { BaseData, ExportableEntity } from '@shared/models/base-data'; +import { HasTenantId } from '@shared/models/entity.models'; +import { AiModelId } from '@shared/models/id/ai-model-id'; + +export interface AiModel extends Omit, 'label'>, HasTenantId, ExportableEntity { + modelType: ModelType; + configuration: { + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + fileName?: string; + region?: string; + accessKeyId?: string; + secretAccessKey?: string; + }; + modelId: string; + temperature?: number; + topP?: number; + topK?: number; + frequencyPenalty?: number; + presencePenalty?: number; + maxOutputTokens?: number; + } +} + +export enum ModelType { + CHAT = 'CHAT' +} + +export enum AiProvider { + OPENAI = 'OPENAI', + AZURE_OPENAI = 'AZURE_OPENAI', + GOOGLE_AI_GEMINI = 'GOOGLE_AI_GEMINI', + GOOGLE_VERTEX_AI_GEMINI = 'GOOGLE_VERTEX_AI_GEMINI', + MISTRAL_AI = 'MISTRAL_AI', + ANTHROPIC = 'ANTHROPIC', + AMAZON_BEDROCK = 'AMAZON_BEDROCK', + GITHUB_MODELS = 'GITHUB_MODELS' +} + +export const AiProviderTranslations = new Map( + [ + [AiProvider.OPENAI , 'ai-models.ai-providers.openai'], + [AiProvider.AZURE_OPENAI , 'ai-models.ai-providers.azure-openai'], + [AiProvider.GOOGLE_AI_GEMINI , 'ai-models.ai-providers.google-ai-gemini'], + [AiProvider.GOOGLE_VERTEX_AI_GEMINI , 'ai-models.ai-providers.google-vertex-ai-gemini'], + [AiProvider.MISTRAL_AI , 'ai-models.ai-providers.mistral-ai'], + [AiProvider.ANTHROPIC , 'ai-models.ai-providers.anthropic'], + [AiProvider.AMAZON_BEDROCK , 'ai-models.ai-providers.amazon-bedrock'], + [AiProvider.GITHUB_MODELS , 'ai-models.ai-providers.github-models'] + ] +); + +export const ProviderFieldsAllList = [ + 'apiKey', + 'personalAccessToken', + 'projectId', + 'location', + 'serviceAccountKey', + 'fileName', + 'endpoint', + 'serviceVersion', + 'region', + 'accessKeyId', + 'secretAccessKey' +]; + +export const ModelFieldsAllList = ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens']; + +export const AiModelMap = new Map([ + [ + AiProvider.OPENAI, + { + modelList: [ + 'o4-mini', + 'o3-pro', + 'o3', + 'o3-mini', + 'o1', + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-4o', + 'gpt-4o-mini', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AZURE_OPENAI, + { + modelList: [], + providerFieldsList: ['apiKey', 'endpoint', 'serviceVersion'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GOOGLE_VERTEX_AI_GEMINI, + { + modelList: [ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-flash-lite', + ], + providerFieldsList: ['projectId', 'location', 'serviceAccountKey', 'fileName'], + modelFieldsList: ['temperature', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.MISTRAL_AI, + { + modelList: [ + 'magistral-medium-latest', + 'magistral-small-latest', + 'mistral-large-latest', + 'mistral-medium-latest', + 'mistral-small-latest', + 'pixtral-large-latest', + 'ministral-8b-latest', + 'ministral-3b-latest', + 'open-mistral-nemo', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], + [ + AiProvider.ANTHROPIC, + { + modelList: [ + 'claude-opus-4-0', + 'claude-sonnet-4-0', + 'claude-3-7-sonnet-latest', + 'claude-3-5-sonnet-latest', + 'claude-3-5-haiku-latest', + ], + providerFieldsList: ['apiKey'], + modelFieldsList: ['temperature', 'topP', 'topK', 'maxOutputTokens'], + }, + ], + [ + AiProvider.AMAZON_BEDROCK, + { + modelList: [], + providerFieldsList: ['region', 'accessKeyId', 'secretAccessKey'], + modelFieldsList: ['temperature', 'topP', 'maxOutputTokens'], + }, + ], + [ + AiProvider.GITHUB_MODELS, + { + modelList: [], + providerFieldsList: ['personalAccessToken'], + modelFieldsList: ['temperature', 'topP', 'frequencyPenalty', 'presencePenalty', 'maxOutputTokens'], + }, + ], +]); + +export const AiRuleNodeResponseFormatTypeOnlyText: AiProvider[] = [AiProvider.AMAZON_BEDROCK, AiProvider.ANTHROPIC, AiProvider.GITHUB_MODELS]; + +export enum ResponseFormat { + TEXT = 'TEXT', + JSON = 'JSON', + JSON_SCHEMA = 'JSON_SCHEMA' +} + +export interface AiModelWithUserMsg { + userMessage: { + contents: Array<{contentType: string; text: string}>; + } + chatModelConfig: { + modelType: string; + provider: AiProvider + providerConfig: { + apiKey?: string; + personalAccessToken?: string; + endpoint?: string; + serviceVersion?: string; + projectId?: string; + location?: string; + serviceAccountKey?: string; + fileName?: string + }; + modelId: string; + maxRetries: number; + timeoutSeconds: number; + } +} + +export interface CheckConnectivityResult { + status: string; + errorDetails: string; +} diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index fbb3e73216..1617e46b58 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -126,8 +126,11 @@ export const HelpLinks = { ruleNodeCalculatedFields: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#calculated-fields-node`, ruleNodeClearAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#clear-alarm-node`, ruleNodeCreateAlarm: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-alarm-node`, + ruleNodeCopyToView: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#copy-to-view-node`, ruleNodeCreateRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#create-relation-node`, ruleNodeDeleteRelation: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-relation-node`, + ruleNodeDeviceState: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-state-node`, + ruleNodeMessageCount: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#message-count-node`, ruleNodeMsgDelay: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delay-node-deprecated`, ruleNodeMsgGenerator: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#generator-node`, ruleNodeGpsGeofencingEvents: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#gps-geofencing-events-node`, @@ -135,10 +138,12 @@ export const HelpLinks = { ruleNodeRpcCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-reply-node`, ruleNodeRpcCallRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rpc-call-request-node`, ruleNodeSaveAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-attributes-node`, + ruleNodeDeleteAttributes: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#delete-attributes-node`, ruleNodeSaveTimeseries: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-timeseries-node`, - ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table`, + ruleNodeSaveToCustomTable: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#save-to-custom-table-node`, ruleNodeRuleChain: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#rule-chain-node`, ruleNodeOutputNode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#output-node`, + ruleNodeAiRequest: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#ai-request-node`, ruleNodeAwsLambda: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-lambda-node`, ruleNodeAwsSns: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sns-node`, ruleNodeAwsSqs: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#aws-sqs-node`, @@ -154,6 +159,7 @@ export const HelpLinks = { ruleNodeRestCallReply: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#rest-call-reply-node`, ruleNodePushToCloud: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-cloud`, ruleNodePushToEdge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#push-to-edge`, + ruleNodeDeviceProfile: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/action-nodes/#device-profile-node`, ruleNodeAcknowledge: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#acknowledge-node`, ruleNodeCheckpoint: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/flow-nodes/#checkpoint-node`, ruleNodeSendNotification: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/rule-engine-2-0/external-nodes/#send-notification-node`, @@ -200,6 +206,7 @@ export const HelpLinks = { mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`, mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`, calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`, + aiModels: `${helpBaseUrl}/docs${docPlatformPrefix}/ai-models`, timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`, trendzSettings: `${helpBaseUrl}/docs/trendz/` } diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 48cad42e7b..6e7ba24578 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -51,6 +51,7 @@ export enum EntityType { MOBILE_APP_BUNDLE = 'MOBILE_APP_BUNDLE', MOBILE_APP = 'MOBILE_APP', CALCULATED_FIELD = 'CALCULATED_FIELD', + AI_MODEL = 'AI_MODEL', } export enum AliasEntityType { @@ -346,6 +347,8 @@ export const entityTypeTranslations = new Map, HasTenantId { pkgName: string; + title?: string; appSecret: string; platformType: PlatformType; status: MobileAppStatus; diff --git a/ui-ngx/src/app/shared/models/ota-package.models.ts b/ui-ngx/src/app/shared/models/ota-package.models.ts index e40eb15dea..bba7ea3d04 100644 --- a/ui-ngx/src/app/shared/models/ota-package.models.ts +++ b/ui-ngx/src/app/shared/models/ota-package.models.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { BaseData } from '@shared/models/base-data'; +import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { TenantId } from '@shared/models/id/tenant-id'; import { OtaPackageId } from '@shared/models/id/ota-package-id'; import { DeviceProfileId } from '@shared/models/id/device-profile-id'; @@ -86,7 +86,7 @@ export interface OtaPagesIds { softwareId?: OtaPackageId; } -export interface OtaPackageInfo extends BaseData, HasTenantId { +export interface OtaPackageInfo extends Omit, 'label'>, HasTenantId, ExportableEntity { tenantId?: TenantId; type: OtaUpdateType; deviceProfileId?: DeviceProfileId; diff --git a/ui-ngx/src/app/shared/models/public-api.ts b/ui-ngx/src/app/shared/models/public-api.ts index d128eaec43..91e1a2b033 100644 --- a/ui-ngx/src/app/shared/models/public-api.ts +++ b/ui-ngx/src/app/shared/models/public-api.ts @@ -63,4 +63,5 @@ export * from './window-message.model'; export * from './usage.models'; export * from './query/query.models'; export * from './regex.constants'; -export * from './trendz-settings.models' +export * from './trendz-settings.models'; +export * from './ai-model.models'; diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index dff6e53654..8225fbf18f 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -483,8 +483,11 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode': 'ruleNodeCalculatedFields', 'org.thingsboard.rule.engine.action.TbClearAlarmNode': 'ruleNodeClearAlarm', 'org.thingsboard.rule.engine.action.TbCreateAlarmNode': 'ruleNodeCreateAlarm', + 'org.thingsboard.rule.engine.action.TbCopyAttributesToEntityViewNode': 'ruleNodeCopyToView', 'org.thingsboard.rule.engine.action.TbCreateRelationNode': 'ruleNodeCreateRelation', 'org.thingsboard.rule.engine.action.TbDeleteRelationNode': 'ruleNodeDeleteRelation', + 'org.thingsboard.rule.engine.action.TbDeviceStateNode': 'ruleNodeDeviceState', + 'org.thingsboard.rule.engine.action.TbMsgCountNode': 'ruleNodeMessageCount', 'org.thingsboard.rule.engine.delay.TbMsgDelayNode': 'ruleNodeMsgDelay', 'org.thingsboard.rule.engine.debug.TbMsgGeneratorNode': 'ruleNodeMsgGenerator', 'org.thingsboard.rule.engine.geo.TbGpsGeofencingActionNode': 'ruleNodeGpsGeofencingEvents', @@ -492,9 +495,11 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode': 'ruleNodeRpcCallReply', 'org.thingsboard.rule.engine.rpc.TbSendRPCRequestNode': 'ruleNodeRpcCallRequest', 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode': 'ruleNodeSaveAttributes', + 'org.thingsboard.rule.engine.telemetry.TbMsgDeleteAttributesNode': 'ruleNodeDeleteAttributes', 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode': 'ruleNodeSaveTimeseries', 'org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode': 'ruleNodeSaveToCustomTable', 'org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode': 'ruleNodeAwsLambda', + 'org.thingsboard.rule.engine.ai.TbAiNode': 'ruleNodeAiRequest', 'org.thingsboard.rule.engine.aws.sns.TbSnsNode': 'ruleNodeAwsSns', 'org.thingsboard.rule.engine.aws.sqs.TbSqsNode': 'ruleNodeAwsSqs', 'org.thingsboard.rule.engine.kafka.TbKafkaNode': 'ruleNodeKafka', @@ -506,6 +511,7 @@ const ruleNodeClazzHelpLinkMap = { 'org.thingsboard.rule.engine.sms.TbSendSmsNode': 'ruleNodeSendSms', 'org.thingsboard.rule.engine.edge.TbMsgPushToCloudNode': 'ruleNodePushToCloud', 'org.thingsboard.rule.engine.edge.TbMsgPushToEdgeNode': 'ruleNodePushToEdge', + 'org.thingsboard.rule.engine.profile.TbDeviceProfileNode': 'ruleNodeDeviceProfile', 'org.thingsboard.rule.engine.flow.TbRuleChainInputNode': 'ruleNodeRuleChain', 'org.thingsboard.rule.engine.flow.TbRuleChainOutputNode': 'ruleNodeOutputNode', 'org.thingsboard.rule.engine.flow.TbAckNode': 'ruleNodeAcknowledge', diff --git a/ui-ngx/src/app/shared/models/vc.models.ts b/ui-ngx/src/app/shared/models/vc.models.ts index 3795518ffc..9a4a68e005 100644 --- a/ui-ngx/src/app/shared/models/vc.models.ts +++ b/ui-ngx/src/app/shared/models/vc.models.ts @@ -33,16 +33,20 @@ export const exportableEntityTypes: Array = [ EntityType.WIDGET_TYPE, EntityType.WIDGETS_BUNDLE, EntityType.TB_RESOURCE, + EntityType.OTA_PACKAGE, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, - EntityType.NOTIFICATION_RULE + EntityType.NOTIFICATION_RULE, + EntityType.AI_MODEL, ]; -export const entityTypesWithoutRelatedData: Set = new Set([ +export const entityTypesWithoutRelatedData = new Set([ EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_RULE, - EntityType.TB_RESOURCE + EntityType.TB_RESOURCE, + EntityType.OTA_PACKAGE, + EntityType.AI_MODEL, ]); export interface VersionCreateConfig { diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index ec7219d78a..9fd6e4a71e 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -276,6 +276,8 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: { + hasBackdrop: true, + backdropClass: 'cdk-overlay-transparent-backdrop', hideSingleSelectionIndicator: true } }, diff --git a/ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md b/ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md new file mode 100644 index 0000000000..e2577244c1 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulenode/ai_node_prompt_settings.md @@ -0,0 +1,75 @@ +#### Example Usage: AI-Powered Alarm Analysis +This example demonstrates how to use the AI node to automatically analyze a new device alarm, generate a human-readable summary, and suggest troubleshooting steps. + +##### Scenario +An IoT freezer unit generates a "High Temperature" alarm. We want the AI to process this alarm data to create a clear summary and a recommended action plan for an operator. + +1. **Incoming message structure** + +Message body (represents an alarm, some alarm fields omitted for brevity): +```json +{ + "type": "High Temperature", + "details": { + "currentTemp_C": -5, + "threshold_C": -18 + } +} +``` + +Message metadata: +```json +{ + "deviceName": "Freezer-B7", + "deviceType": "CommercialFreezer" +} +``` + +2. **Prompt configuration** + +**System prompt** + +Here, we set the AI's role and enforce a strict JSON output format. This ensures the output is always machine-parsable. +``` +You are an expert AI assistant for IoT operations. +Your task is to analyze device data and respond with a single, valid JSON object. +Do not include any text, explanations, or markdown formatting before or after the JSON output. +``` + +**User prompt** + +This prompt defines the specific task, using templates to dynamically insert data from the incoming alarm message. +``` +Analyze the following alarm from a "${deviceType}" unit named "${deviceName}". + +Alarm Data: +$[*] + +Based on the alarm data, generate a JSON object with two keys: +1. "summary": A brief, human-readable summary of the event. +2. "action": A concrete, recommended next step for an operator. +``` + +3. **How it works** + +When the alarm message from "Freezer-B7" is processed by the AI node, the templates are substituted with the actual data: +- `${deviceName}` becomes "Freezer-B7" +- `${deviceType}` becomes "CommercialFreezer" +- `$[*]` is replaced by the entire message body JSON: `{"type":"High Temperature","details":{"currentTemp_C":-5,"threshold_C":-18}}` +> **Note:** You can also use `${*}`. In this case, it will be replaced with the entire message metadata JSON. + +The final instruction sent to the AI is a combination of the system and the substituted user prompt. + +4. **Expected AI output** + +Given the combined instructions, the AI would generate the following structured JSON output, which can then be used in subsequent rule nodes (e.g., to send an enriched email). + +```json +{ + "summary": "Critical high temperature alert on freezer unit Freezer-B7. The current temperature is -5°C, which is significantly above the required threshold of -18°C.", + "action": "Dispatch technician immediately to inspect the unit's cooling system and ensure the door is properly sealed. Investigate for potential power issues." +} +``` + +> **Note:** The scenario above is a hypothetical example designed to illustrate the functionality of the node and its templating capabilities. +> The specific details, such as freezer alarms, are used for demonstration purposes and are not intended to suggest or limit the potential use cases. diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 9444c8e747..0e31628f1f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -721,6 +721,7 @@ "state-entity-parameter-name": "State entity parameter name", "default-state-entity": "Default state entity", "default-entity-parameter-name": "By default", + "query-options": "Query options", "max-relation-level": "Max relation level", "unlimited-level": "Unlimited level", "state-entity": "Dashboard state entity", @@ -1089,6 +1090,83 @@ "use-latest-timestamp": "If enabled, the calculated value will be persisted using the most recent timestamp from the arguments telemetry, instead of the server time." } }, + "ai-models": { + "ai-models": "AI models", + "ai-model": "AI model", + "model": "Model", + "name": "Name", + "ai-provider": "AI provider", + "no-found": "No AI models found", + "list": "{ count, plural, =1 {One model} other {List of # models} }", + "selected-fields": "{ count, plural, =1 {1 model} other {# models} } selected", + "add": "Add model", + "delete-model-title": "Are you sure you want to delete the model '{{modelName}}'?", + "delete-model-text": "Be careful, after the confirmation the model and all related data will become unrecoverable.", + "delete-models-title": "Are you sure you want to delete { count, plural, =1 {1 model} other {# models} }?", + "delete-models-text": "Be careful, after the confirmation all selected models will be removed and all related data will become unrecoverable.", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Models" + }, + "name-required": "Name is required.", + "name-max-length": "Name must be 255 characters or less.", + "provider": "Provider", + "api-key": "API key", + "api-key-required": "API key is required.", + "project-id": "Project ID", + "project-id-required": "Project ID is required", + "location": "Location", + "location-required": "Location is required.", + "service-account-key-file": "Service account key file", + "service-account-key-file-required": "Service account key file is required.", + "no-file": "No file selected.", + "drop-file": "Drop a file or click to select a file to upload.", + "personal-access-token": "Personal access token", + "personal-access-token-required": "Personal access token is required.", + "configuration": "Configuration", + "model-id": "Model ID", + "model-id-required": "Model ID is required.", + "deployment-name": "Deployment name", + "deployment-name-required": "Deployment name is required", + "set": "Set", + "region": "Region", + "region-required": "Region is required.", + "access-key-id": "Access key ID", + "access-key-id-required": "Access key ID is required.", + "secret-access-key": "Secret access key", + "secret-access-key-required": "Secret access key is required.", + "temperature": "Temperature", + "temperature-hint": "Adjusts the level of randomness in the model's output. Higher values increase randomness, while lower values decrease it.", + "temperature-min": "Must be 0 or greater.", + "top-p": "Top P", + "top-p-hint": "Creates a pool of the most probable tokens for the model to choose from. Higher values create a larger and more diverse pool, while lower values create a smaller one.", + "top-p-min-max": "Must be greater than 0 and up to 1.", + "top-k": "Top K", + "top-k-hint": "Restricts the model's choices to a fixed set of the \"K\" most likely tokens.", + "top-k-min": "Must be 0 or greater.", + "presence-penalty": "Presence penalty", + "presence-penalty-hint": "Applies a fixed penalty to the likelihood of a token if it has already appeared in the text.", + "frequency-penalty": "Frequency penalty", + "frequency-penalty-hint": "Applies a penalty to a token's likelihood that increases based on its frequency in the text.", + "max-output-tokens": "Maximum output tokens", + "max-output-tokens-min": "Must be greater than 0.", + "max-output-tokens-hint": "Sets the maximum number of tokens that the \nmodel can generate in a single response.", + "endpoint": "Endpoint", + "endpoint-required": "Endpoint is required.", + "service-version": "Service version", + "check-connectivity": "Check connectivity", + "check-connectivity-success": "Test request was successful", + "check-connectivity-failed": "Test request failed", + "no-model-matching": "No models matching '{{entity}}' were found.", + "model-required": "Model is required.", + "no-model-text": "No models found." + }, "confirm-on-exit": { "message": "You have unsaved changes. Are you sure you want to leave this page?", "html-message": "You have unsaved changes.
Are you sure you want to leave this page?", @@ -2555,6 +2633,8 @@ "type-current-user-owner": "Current User Owner", "type-calculated-field": "Calculated field", "type-calculated-fields": "Calculated fields", + "type-ai-model": "AI model", + "type-ai-models": "AI models", "type-widgets-bundle": "Widgets bundle", "type-widgets-bundles": "Widgets bundles", "list-of-widgets-bundles": "{ count, plural, =1 {One widgets bundle} other {List of # widget bundles} }", @@ -2584,6 +2664,8 @@ "type-tb-resources": "Resources", "list-of-tb-resources": "{ count, plural, =1 {One resource} other {List of # resources} }", "type-ota-package": "OTA package", + "type-ota-packages": "OTA packages", + "list-of-ota-packages": "{ count, plural, =1 {One OTA package} other {List of # OTA packages} }", "type-rpc": "RPC", "type-queue": "Queue", "type-queue-stats": "Queue statistics", @@ -3729,9 +3811,9 @@ }, "mobile": { "add-application": "Add application", - "app-id": "App ID", - "app-id-required": "App ID is required", - "app-id-pattern": "Invalid format App ID", + "app-id": "App Site Association ID", + "app-id-required": "App Site Association ID is required", + "app-id-pattern": "Invalid format App Site Association ID", "app-store-link": "App Store link", "app-store-link-required": "App Store link is required", "application-details": "Application details", @@ -3765,6 +3847,8 @@ "mobile-package-max-length": "Application package should be less than 256", "mobile-package-required": "Application package is required.", "mobile-package-pattern": "Application package invalid format", + "mobile-package-title": "Application title", + "mobile-package-title-max-length": "Application title should be less than 256", "no-application": "No applications found", "no-bundles": "No bundles found", "platform-type": "Platform type", @@ -3848,17 +3932,13 @@ "prepare-environment-text": "Flutter ThingsBoard Mobile Application requires Flutter SDK. Follow instructions to set up Flutter SDK.", "get-source-code-title": "Get app source code", "get-source-code-text": "You can get Flutter ThingsBoard Mobile Application source code by cloning it from the GitHub repository:", - "configure-api-title": "Configure ThingsBoard API endpoint", - "configure-api-text": "Open the flutter_thingsboard_app project in your editor/IDE. Edit:", - "configure-api-hint": "Set the value of the thingsBoardApiEndpoint constant to match the API endpoint of your ThingsBoard server instance. Do not use “localhost” or “127.0.0.1” hostnames.", + "configure-app-settings-title": "Configure app settings", + "configure-app-settings-text": "Download the configuration file and place it into the root directory of the project you cloned in the previous step.", + "download-file": "Download file", "run-app-title": "Run the app", "run-app-text": "Run the app as described in your IDE.\nIf using the terminal, run the app with the following command:", "more-information": "Detailed information may be found in our Getting Started documentation.", - "getting-started": "Getting Started", - "configure-package-title": "Configure application package", - "configure-package-text": "You can manually change the Application Package or use third party CLI tool.", - "configure-package-text-install": "To install the Rename CLI Tool, execute the following command:", - "configure-package-run-commands": "Run these commands in the root directory of your project:" + "getting-started": "Getting Started" } }, "notification": { @@ -4365,6 +4445,7 @@ "add-relation-filter": "Add relation filter", "any-relation": "Any relation", "relation-filters": "Relation filters", + "relation-filter": "Relation filter", "additional-info": "Additional info (JSON)", "invalid-additional-info": "Unable to parse additional info json.", "no-relations-text": "No relations found", @@ -5355,6 +5436,36 @@ "html-text-description": "Allows you to use HTML tags for formatting, links and images in your mai body.", "dynamic-text-description": "Allows to use Plain Text or HTML body type dynamically based on templatization feature.", "after-template-evaluation-hint": "After template evaluation value should be true for HTML, and false for Plain text." + }, + "ai": { + "ai-model": "AI model", + "model": "Model", + "ai-model-hint": "Select the pre-configured AI model to process requests sent by this rule node, or use \"Create new\" to configure a new one.", + "prompt-settings": "Prompt settings", + "prompt-settings-hint": "The optional system prompt sets the AI's general role and constraints, while the user prompt defines the specific task to perform. Both fields also support templatization.", + "system-prompt": "System prompt", + "system-prompt-max-length": "System prompt must be 10000 characters or less.", + "system-prompt-blank": "System prompt must not be blank.", + "user-prompt": "User prompt", + "user-prompt-required": "User prompt is required.", + "user-prompt-max-length": "User prompt must be 10000 characters or less.", + "user-prompt-blank": "User prompt must not be blank.", + "response-format": "Response format", + "response-text": "Text", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "Allows the model to generate arbitrary text, which may or may not be a valid JSON object. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-format-hint-JSON": "The model is required to generate a response that is a valid JSON. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-format-hint-JSON_SCHEMA": "The model is required to generate a JSON that matches the specific structure and data types defined in the provided schema. If the output is not a valid JSON object, it will be automatically wrapped within a JSON object under the \"response\" key.", + "response-json-schema-hint": "While any valid JSON Schema can be entered, this rule node only supports a limited subset of its features. See node documentation for details.", + "response-json-schema-required": "JSON Schema is required", + "advanced-settings": "Advanced settings", + "timeout": "Timeout", + "timeout-hint": "Maximum time to wait for a response \nfrom the AI model before the request is terminated.", + "timeout-required": "Timeout is required", + "timeout-validation": "Must be from 1 second to 10 minutes.", + "force-acknowledgement": "Force acknowledgement", + "force-acknowledgement-hint": "If enabled, the incoming message is acknowledged immediately. The model's response is then enqueued as a separate, new message." } }, "timezone": { diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index e516810895..b88145331c 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -163,6 +163,9 @@ .tb-form-panel-title { font-weight: 500; font-size: 16px; + &.tb-normal { + font-weight: normal; + } &.tb-required::after { font-size: 13px; @@ -819,4 +822,41 @@ } } } + + .tb-form-panel.outlined { + --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mdc-outlined-text-field-container-shape: 6px; + --mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56); + + box-shadow: none; + gap: 0; + padding-bottom: 0; + + &:not(.stroked) { + border-radius: 0; + } + + &:not(.mat-padding,.padding) { + padding: 0; + } + + & > .tb-form-panel-title { + margin-bottom: 16px; + } + + .tb-form-panel { + @media #{$mat-xs} { + gap: 16px; + } + } + + .tb-form-row { + height: 56px; + margin-bottom: 22px; + &.disabled { + border-color: var(--mdc-outlined-text-field-disabled-outline-color); + color: var(--mdc-outlined-text-field-disabled-input-text-color); + } + } + } } diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 6e86f3c2b0..dc79929cc8 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -3740,6 +3740,14 @@ cacache@^18.0.0: tar "^6.1.11" unique-filename "^3.0.0" +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -4788,6 +4796,15 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.3" +dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + earcut@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.1.tgz#f60b3f671c5657cca9d3e131c5527c5dde00ef38" @@ -4968,6 +4985,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -4985,6 +5007,13 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" @@ -4994,6 +5023,16 @@ es-set-tostringtag@^2.0.3: has-tostringtag "^1.0.2" hasown "^2.0.1" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es-shim-unscopables@^1.0.0, es-shim-unscopables@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" @@ -5508,13 +5547,15 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" -form-data@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== +form-data@4.0.0, form-data@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" formdata-polyfill@^4.0.10: @@ -5635,6 +5676,30 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.6: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-stream@^6.0.0, get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" @@ -5750,6 +5815,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.4, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -5807,6 +5877,11 @@ has-symbols@^1.0.2, has-symbols@^1.0.3: resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" @@ -6970,6 +7045,11 @@ marked@~12.0.2: resolved "https://registry.yarnpkg.com/marked/-/marked-12.0.2.tgz#b31578fe608b599944c69807b00f18edab84647e" integrity sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q== +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"