Browse Source

Merge remote-tracking branch 'dskarzh/ai' into AD/imp/ai-models

pull/13371/head
Artem Dzhereleiko 1 year ago
parent
commit
536075bb3b
  1. 658
      application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java
  2. 2
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java
  3. 2
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java
  4. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java
  5. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java
  6. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java
  7. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java
  8. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java
  9. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java
  10. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java
  11. 4
      common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java
  12. 2
      common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgMetaData.java
  13. 24
      common/util/src/main/java/org/thingsboard/common/util/AzureIotHubUtil.java
  14. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java
  15. 2
      dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java
  16. 5
      dao/src/test/resources/application-test.properties
  17. 20
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java
  18. 4
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java
  19. 2
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java
  20. 12
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNode.java
  21. 10
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/azure/TbAzureIotHubNodeTest.java

658
application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java

@ -0,0 +1,658 @@
/**
* 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.boot.test.mock.mockito.SpyBean;
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.audit.ActionType;
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 org.thingsboard.server.service.entitiy.TbLogEntityActionService;
import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@DaoSqlTest
public class AiModelControllerTest extends AbstractControllerTest {
@SpyBean
private EntitiesVersionControlService versionControlService;
@SpyBean
private TbLogEntityActionService logEntityActionService;
/* --- 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();
// verify auto-commit
then(versionControlService).should().autoCommit(
argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), eq(savedModel.getId())
);
// verify a rule engine message was sent, and an audit log was created
then(logEntityActionService).should().logEntityAction(
eq(tenantId),
eq(savedModel.getId()),
eq(savedModel),
eq(ActionType.ADDED),
argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId()))
);
}
@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();
// verify auto-commit
then(versionControlService).should(times(2)).autoCommit(
argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())), eq(updatedModel.getId())
);
// verify a rule engine message was sent, and an audit log was created
then(logEntityActionService).should().logEntityAction(
eq(tenantId), eq(updatedModel.getId()), eq(updatedModel), eq(ActionType.UPDATED),
argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId()))
);
}
/* --- 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<AiModel> 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_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<AiModel> result1 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "google ai", sortOrder));
PageData<AiModel> result2 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "pro", sortOrder));
PageData<AiModel> result3 = doGetTypedWithPageLink("/api/ai/model?", new TypeReference<>() {}, new PageLink(pageSize, page, "test", sortOrder));
PageData<AiModel> 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<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("createdTime", SortOrder.Direction.ASC))
);
PageData<AiModel> 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<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("name", SortOrder.Direction.ASC))
);
PageData<AiModel> 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<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC))
);
PageData<AiModel> 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<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("modelId", SortOrder.Direction.ASC))
);
PageData<AiModel> 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<AiModel> resultAsc = doGetTypedWithPageLink(
"/api/ai/model?", new TypeReference<>() {},
new PageLink(pageSize, page, textSearch, SortOrder.of("provider", SortOrder.Direction.ASC))
);
PageData<AiModel> 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 a rule engine message was sent, and an audit log was created
then(logEntityActionService).should().logEntityAction(
eq(tenantId),
eq(model.getId()),
eq(model),
eq(ActionType.DELETED),
argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())),
eq(model.getId().toString())
);
// 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();
// verify a rule engine message was not sent, and an audit log was not created
then(logEntityActionService).should(never()).logEntityAction(
eq(tenantId),
eq(nonexistentModelId),
any(AiModel.class),
eq(ActionType.DELETED),
argThat(actualUser -> Objects.equals(actualUser.getId(), tenantAdminUser.getId())),
eq(nonexistentModelId.toString())
);
}
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();
}
}

2
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.common.data.ai.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig;
@ -74,7 +73,6 @@ public interface AiModelConfig {
})
AiProviderConfig providerConfig();
@JsonProperty("modelType")
AiModelType modelType();
}

2
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AiChatModelConfig.java

@ -15,6 +15,7 @@
*/
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;
@ -28,6 +29,7 @@ public sealed interface AiChatModelConfig<C extends AiChatModelConfig<C>> extend
ChatModel configure(Langchain4jChatModelConfigurer configurer);
@Override
@JsonProperty(value = "modelType", access = JsonProperty.Access.READ_ONLY)
default AiModelType modelType() {
return AiModelType.CHAT;
}

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig;
@Builder
public record AmazonBedrockChatModelConfig(
AiModelType modelType,
@NotNull @Valid AmazonBedrockProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig;
@Builder
public record AnthropicChatModelConfig(
AiModelType modelType,
@NotNull @Valid AnthropicProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig;
@Builder
public record AzureOpenAiChatModelConfig(
AiModelType modelType,
@NotNull @Valid AzureOpenAiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig;
@Builder
public record GitHubModelsChatModelConfig(
AiModelType modelType,
@NotNull @Valid GitHubModelsProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig;
@Builder
public record GoogleAiGeminiChatModelConfig(
AiModelType modelType,
@NotNull @Valid GoogleAiGeminiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig;
@Builder
public record GoogleVertexAiGeminiChatModelConfig(
AiModelType modelType,
@NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig;
@Builder
public record MistralAiChatModelConfig(
AiModelType modelType,
@NotNull @Valid MistralAiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

4
common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java

@ -22,13 +22,13 @@ 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.model.AiModelType;
import org.thingsboard.server.common.data.ai.provider.AiProvider;
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig;
@Builder
public record OpenAiChatModelConfig(
AiModelType modelType,
@NotNull @Valid OpenAiProviderConfig providerConfig,
@NotBlank String modelId,
@PositiveOrZero Double temperature,

2
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;
@ -64,6 +65,7 @@ public final class TbMsgMetaData implements Serializable {
return new TbMsgMetaData(data);
}
@JsonIgnore
public boolean isEmpty() {
return data == null || data.isEmpty();
}

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

6
dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiModelRepository.java

@ -42,8 +42,8 @@ interface AiModelRepository extends JpaRepository<AiModelEntity, UUID>, Exportab
WHERE model.tenant_id = :tenantId
AND (:textSearch IS NULL
OR model.name ILIKE '%' || :textSearch || '%'
OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%'
OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%')
OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%'
OR model.configuration ->> 'modelId' ILIKE '%' || :textSearch || '%')
""",
countQuery = """
SELECT COUNT(*)
@ -51,7 +51,7 @@ interface AiModelRepository extends JpaRepository<AiModelEntity, UUID>, Exportab
WHERE model.tenant_id = :tenantId
AND (:textSearch IS NULL
OR model.name ILIKE '%' || :textSearch || '%'
OR (model.configuration ->> 'provider') ILIKE '%' || :textSearch || '%'
OR REPLACE(model.configuration ->> 'provider', '_', ' ') ILIKE '%' || :textSearch || '%'
OR (model.configuration ->> 'modelId') ILIKE '%' || :textSearch || '%')
""",
nativeQuery = true

2
dao/src/main/java/org/thingsboard/server/dao/sql/ai/JpaAiModelDao.java

@ -89,7 +89,7 @@ class JpaAiModelDao extends JpaAbstractDao<AiModelEntity, AiModel> implements Ai
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);
}

5
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
spring.jpa.properties.hibernate.dialect=org.thingsboard.server.dao.ThingsboardPostgreSQLDialect

20
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNode.java

@ -52,9 +52,20 @@ import static org.thingsboard.server.dao.service.ConstraintValidator.validateFie
@RuleNode(
type = ComponentType.EXTERNAL,
name = "AI",
nodeDescription = "Interact with AI",
nodeDetails = "This node makes requests to AI based on a prompt and a input message and returns a response in a form of output message",
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 <strong>AI model</strong> and define its behavior using a <strong>system prompt</strong> (optional context or role) and a <strong>user prompt</strong> (the main task).
Both prompts can be populated with data and metadata from the incoming message using patterns.
For example, the <code>$[*]</code> and <code>${*}</code> patterns allow you to access the all message body and all metadata, respectively.
<br><br>
After sending the request, the node waits for a response within a configured <strong>timeout</strong>.
You can specify the desired <strong>response format</strong> as <strong>Text</strong>, <strong>JSON</strong>, or provide a specific <strong>JSON Schema</strong> 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.
<br><br>
Output connections: <code>Success</code>, <code>Failure</code>.
""",
configClazz = TbAiNodeConfiguration.class,
configDirective = "tbExternalNodeAiConfig",
ruleChainTypes = RuleChainType.CORE
@ -87,7 +98,8 @@ public final class TbAiNode extends TbAbstractExternalNode implements TbNode {
systemPrompt = config.getSystemPrompt();
userPrompt = config.getUserPrompt();
timeoutSeconds = config.getTimeoutSeconds();
modelId = config.getAiModelId();
modelId = config.getModelId();
super.forceAck = config.isForceAck() || super.forceAck; // force ack if node config says so, or if env variable (super.forceAck) says so
Optional<AiModel> model = ctx.getAiModelService().findAiModelByTenantIdAndId(ctx.getTenantId(), modelId);
if (model.isEmpty()) {

4
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/ai/TbAiNodeConfiguration.java

@ -32,7 +32,7 @@ import static org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseForm
public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfiguration> {
@NotNull
private AiModelId aiModelId;
private AiModelId modelId;
@Pattern(regexp = ".*\\S.*", message = "must not be blank")
@Length(min = 1, max = 10000)
@ -50,6 +50,8 @@ public class TbAiNodeConfiguration implements NodeConfiguration<TbAiNodeConfigur
@Max(value = 600, message = "cannot exceed 600 seconds (10 minutes)")
private int timeoutSeconds;
private boolean forceAck;
@Override
public TbAiNodeConfiguration defaultConfiguration() {
var configuration = new TbAiNodeConfiguration();

2
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();

12
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<Boolean, JsonNode> upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException {
boolean hasChanges = false;

10
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

Loading…
Cancel
Save