From 3518be97a4c27266572681b6d7abfbf570cbe844 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 2 Dec 2025 15:19:47 +0200 Subject: [PATCH 1/8] fixed java rest client --- .../msa/connectivity/JavaRestClientTest.java | 318 +++++++++++++++++- .../thingsboard/rest/client/RestClient.java | 58 +++- 2 files changed, 364 insertions(+), 12 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index 636c80d5b3..7cee0a6ba6 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -15,10 +15,14 @@ */ package org.thingsboard.server.msa.connectivity; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.TextNode; +import com.google.gson.JsonObject; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.io.HttpClientConnectionManager; import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; import org.apache.hc.client5.http.ssl.HostnameVerificationPolicy; import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; @@ -30,26 +34,83 @@ import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rest.client.RestClient; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +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.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.domain.Domain; +import org.thingsboard.server.common.data.domain.DomainInfo; +import org.thingsboard.server.common.data.id.NotificationTargetId; +import org.thingsboard.server.common.data.id.NotificationTemplateId; +import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.mobile.app.MobileApp; +import org.thingsboard.server.common.data.mobile.app.MobileAppStatus; +import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundle; +import org.thingsboard.server.common.data.mobile.bundle.MobileAppBundleInfo; +import org.thingsboard.server.common.data.notification.Notification; +import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +import org.thingsboard.server.common.data.notification.NotificationRequest; +import org.thingsboard.server.common.data.notification.NotificationRequestConfig; +import org.thingsboard.server.common.data.notification.NotificationRequestInfo; +import org.thingsboard.server.common.data.notification.NotificationRequestPreview; +import org.thingsboard.server.common.data.notification.NotificationType; +import org.thingsboard.server.common.data.notification.settings.NotificationSettings; +import org.thingsboard.server.common.data.notification.settings.SlackNotificationDeliveryMethodConfig; +import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; +import org.thingsboard.server.common.data.notification.targets.NotificationTarget; +import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; +import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; +import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; +import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.notification.template.HasSubject; +import org.thingsboard.server.common.data.notification.template.MobileAppDeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.notification.template.NotificationTemplate; +import org.thingsboard.server.common.data.notification.template.NotificationTemplateConfig; +import org.thingsboard.server.common.data.notification.template.SmsDeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.notification.template.WebDeliveryMethodNotificationTemplate; +import org.thingsboard.server.common.data.oauth2.PlatformType; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.query.EntityDataPageLink; +import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; +import org.thingsboard.server.common.data.query.EntityKeyType; +import org.thingsboard.server.common.data.query.EntityTypeFilter; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.msa.AbstractContainerTest; import org.thingsboard.server.msa.TestProperties; import javax.net.ssl.SSLContext; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; +import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.EMAIL; +import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.MICROSOFT_TEAMS; +import static org.thingsboard.server.common.data.notification.NotificationDeliveryMethod.WEB; import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype; +import static org.thingsboard.server.msa.ui.utils.EntityPrototypes.defaultTenantAdmin; public class JavaRestClientTest extends AbstractContainerTest { + public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test"; + public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL; private RestClient restClient; + private Tenant tenant; + private User user; @BeforeClass public void beforeClass() throws Exception { @@ -77,11 +138,27 @@ public class JavaRestClientTest extends AbstractContainerTest { @BeforeMethod public void setUp() throws Exception { - restClient.login("tenant@thingsboard.org", "tenant"); + restClient.login("sysadmin@thingsboard.org", "sysadmin"); + + // create tenant and tenant admin + tenant = new Tenant(); + tenant.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.randomAlphabetic(5)); + tenant = restClient.saveTenant(tenant); + + String email = RandomStringUtils.randomAlphabetic(5) + "@gmail.com"; + + user = restClient.saveUser(defaultTenantAdmin(tenant.getId(), email), false); + + restClient.activateUser(user.getId(), "password123", false); + restClient.login(email, "password123"); } @AfterMethod public void tearDown() { + restClient.login("sysadmin@thingsboard.org", "sysadmin"); + if (tenant != null) { + restClient.deleteTenant(tenant.getId()); + } } @Test @@ -123,6 +200,243 @@ public class JavaRestClientTest extends AbstractContainerTest { PageData allClearedAlarms = restClient.getAllAlarms(AlarmSearchStatus.CLEARED, null, new TimePageLink(10, 0), null); assertThat(allClearedAlarms.getData()).hasSize(0); + } + + @Test + public void testTimeSeriesByReadTsKvQueries() { + Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5))); + assertThat(device).isNotNull(); + + DeviceCredentials deviceCredentials = restClient.getDeviceCredentialsByDeviceId(device.getId()).get(); + for (int i = 0; i < 3; i++) { + JsonObject values = new JsonObject(); + values.addProperty("temperature", i + 25); + testRestClient.postTelemetry(deviceCredentials.getCredentialsId(), JacksonUtil.toJsonNode(createPayload().toString())); + } + + restClient.saveEntityTelemetry(device.getId(), "ts", JacksonUtil.toJsonNode("{\"temperature\": 25, \"humidity\": 60}")); + restClient.saveEntityTelemetry(device.getId(), "ts", JacksonUtil.toJsonNode("{\"temperature\": 27, \"humidity\": 59}")); + restClient.saveEntityTelemetry(device.getId(), "ts", JacksonUtil.toJsonNode("{\"temperature\": 33, \"humidity\": 62}")); + + EntityTypeFilter filter = new EntityTypeFilter(); + filter.setEntityType(EntityType.DEVICE); + var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime"), EntityDataSortOrder.Direction.DESC), false); + + var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); + + EntityDataQuery entityDataQuery = new EntityDataQuery(filter, pageLink, entityFields, null, null); + JsonNode result = restClient.findEntityTimeseriesAndAttributesKeysByQuery(entityDataQuery, true, true, null); + assertThat(result).isNotNull(); + assertThat((ArrayNode)result.get("timeseries")).contains(new TextNode("temperature"), new TextNode("humidity")); + } + + @Test + public void testFindNotifications() { + NotificationTarget notificationTarget = createNotificationTarget(user.getId()); + String notificationText1 = "Notification 1"; + NotificationTemplate notificationTemplate = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, notificationText1, new NotificationDeliveryMethod[]{WEB}); + NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationTemplate.getId()); + + String notificationText2 = "Notification 2"; + NotificationTemplate notificationTemplate2 = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, notificationText2, new NotificationDeliveryMethod[]{WEB}); + NotificationRequest notificationRequest2 = submitNotificationRequest(notificationTarget.getId(), notificationTemplate2.getId()); + + PageData initialRequests = restClient.getNotificationRequests(new PageLink(30)); + assertThat(initialRequests.getTotalElements()).isGreaterThanOrEqualTo(2); + + NotificationRequestInfo notificationRequestInfo = restClient.getNotificationRequestById(notificationRequest.getId()).get(); + assertThat(notificationRequestInfo.getName()).isEqualTo(notificationRequest.getName()); + assertThat(notificationRequestInfo.getTemplateName()).isEqualTo(notificationTemplate.getName()); + + NotificationRequestPreview requestPreview = restClient.getNotificationRequestPreview(notificationRequest, 10); + assertThat(requestPreview.getTotalRecipientsCount()).isEqualTo(1); + assertThat(requestPreview.getRecipientsPreview()).isEqualTo(List.of(user.getEmail())); + + PageData notifications = restClient.getNotifications(false, WEB, new PageLink(30)); + assertThat(notifications.getTotalElements()).isEqualTo(2); + + Integer unreadCount = restClient.getUnreadNotificationsCount(WEB); + assertThat(unreadCount).isEqualTo(2); + + restClient.markNotificationAsRead(notifications.getData().get(0).getId()); + + Integer unreadCountAfterRead = restClient.getUnreadNotificationsCount(WEB); + assertThat(unreadCountAfterRead).isEqualTo(1); + + restClient.markAllNotificationsAsRead(WEB); + + Integer unreadCountAfterAllRead = restClient.getUnreadNotificationsCount(WEB); + assertThat(unreadCountAfterAllRead).isEqualTo(0); + + restClient.deleteNotification(notifications.getData().get(0).getId()); + notifications = restClient.getNotifications(false, WEB, new PageLink(30)); + assertThat(notifications.getTotalElements()).isEqualTo(1); + + restClient.deleteNotificationRequest(notificationRequest.getId()); + PageData requestsAfterUpdate = restClient.getNotificationRequests(new PageLink(30)); + assertThat(requestsAfterUpdate.getTotalElements()).isEqualTo(initialRequests.getTotalElements() - 1); + List availableDeliveryMethods = restClient.getAvailableDeliveryMethods(); + assertThat(availableDeliveryMethods).contains(WEB, EMAIL, MICROSOFT_TEAMS); + } + + @Test + public void testSaveNotificationSettings() { + NotificationSettings settings = new NotificationSettings(); + SlackNotificationDeliveryMethodConfig slackConfig = new SlackNotificationDeliveryMethodConfig(); + String slackToken = "xoxb-123123123"; + slackConfig.setBotToken(slackToken); + settings.setDeliveryMethodsConfigs(Map.of( + NotificationDeliveryMethod.SLACK, slackConfig + )); + + restClient.saveNotificationSettings(settings); + + NotificationSettings savedSettings = restClient.getNotificationSettings().get(); + assertThat(savedSettings.getDeliveryMethodsConfigs()).hasSize(1); + assertThat(savedSettings.getDeliveryMethodsConfigs().get(slackConfig.getMethod())).isEqualTo(slackConfig); + + // save user notification settings + var entityActionNotificationPref = new UserNotificationSettings.NotificationPref(); + entityActionNotificationPref.setEnabled(true); + entityActionNotificationPref.setEnabledDeliveryMethods(Map.of( + NotificationDeliveryMethod.WEB, true, + NotificationDeliveryMethod.SMS, false, + NotificationDeliveryMethod.EMAIL, false + )); + + var entitiesLimitNotificationPref = new UserNotificationSettings.NotificationPref(); + entitiesLimitNotificationPref.setEnabled(true); + entitiesLimitNotificationPref.setEnabledDeliveryMethods(Map.of( + NotificationDeliveryMethod.SMS, true, + NotificationDeliveryMethod.WEB, false, + NotificationDeliveryMethod.EMAIL, false + )); + + var apiUsageLimitNotificationPref = new UserNotificationSettings.NotificationPref(); + apiUsageLimitNotificationPref.setEnabled(false); + apiUsageLimitNotificationPref.setEnabledDeliveryMethods(Map.of( + NotificationDeliveryMethod.WEB, true, + NotificationDeliveryMethod.SMS, false, + NotificationDeliveryMethod.EMAIL, false + )); + + UserNotificationSettings userNotificationSettings = new UserNotificationSettings(Map.of( + NotificationType.ENTITY_ACTION, entityActionNotificationPref, + NotificationType.ENTITIES_LIMIT, entitiesLimitNotificationPref, + NotificationType.API_USAGE_LIMIT, apiUsageLimitNotificationPref + )); + UserNotificationSettings saved = restClient.saveUserNotificationSettings(userNotificationSettings); + UserNotificationSettings retrieved = restClient.getUserNotificationSettings().get(); + assertThat(retrieved).isEqualTo(saved); + } + + @Test + public void testSaveDomain() { + restClient.login("sysadmin@thingsboard.org", "sysadmin"); + + Domain domain = new Domain(); + String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + domain.setName(prefix + ".test.com"); + Domain savedDomain = restClient.saveDomain(domain); + assertThat(savedDomain.getName()).isEqualTo(domain.getName()); + + PageData tenantDomainInfos = restClient.getTenantDomainInfos(new PageLink(10)); + List domainInfos = tenantDomainInfos.getData().stream().filter(domainInfo -> domainInfo.getName().startsWith(prefix)).toList(); + assertThat(domainInfos).hasSize(1); + } + + @Test + public void testSaveMobileApp() { + restClient.login("sysadmin@thingsboard.org", "sysadmin"); + + MobileApp mobileApp = new MobileApp(); + String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + mobileApp.setPkgName(prefix + "test.app.apple"); + mobileApp.setPlatformType(PlatformType.ANDROID); + mobileApp.setAppSecret(RandomStringUtils.randomAlphabetic(20)); + mobileApp.setStatus(MobileAppStatus.DRAFT); + + MobileApp savedMobileApp = restClient.saveMobileApp(mobileApp); + assertThat(savedMobileApp.getName()).isEqualTo(mobileApp.getName()); + + PageData mobileApps = restClient.getTenantMobileApps(new PageLink(10)); + List retrieved = mobileApps.getData().stream().filter(app -> app.getPkgName().startsWith(prefix)).toList(); + assertThat(retrieved).hasSize(1); + + MobileAppBundle mobileAppBundle = new MobileAppBundle(); + String bundlePrefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + mobileAppBundle.setTitle(bundlePrefix + "Test Bundle"); + mobileAppBundle.setAndroidAppId(savedMobileApp.getId()); + + MobileAppBundle savedMobileAppBundle = restClient.saveMobileBundle(mobileAppBundle); + PageData mobileBundleInfos = restClient.getTenantMobileBundleInfos(new PageLink(10)); + List bundleInfos = mobileBundleInfos.getData().stream().filter(mobileAppBundleInfo -> mobileAppBundleInfo.getTitle().startsWith(bundlePrefix)).toList(); + assertThat(bundleInfos).hasSize(1); + } + + protected NotificationTarget createNotificationTarget(UserId... usersIds) { + UserListFilter filter = new UserListFilter(); + filter.setUsersIds(Arrays.stream(usersIds).map(UUIDBased::getId).toList()); + return createNotificationTarget(filter); + } + + protected NotificationTarget createNotificationTarget(UsersFilter usersFilter) { + NotificationTarget notificationTarget = new NotificationTarget(); + notificationTarget.setName(usersFilter.toString() + org.apache.commons.lang3.RandomStringUtils.randomNumeric(5)); + PlatformUsersNotificationTargetConfig targetConfig = new PlatformUsersNotificationTargetConfig(); + targetConfig.setUsersFilter(usersFilter); + notificationTarget.setConfiguration(targetConfig); + return restClient.createNotificationTarget(notificationTarget); + } + + protected NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, + String text, NotificationDeliveryMethod... deliveryMethods) { + NotificationTemplate notificationTemplate = new NotificationTemplate(); + notificationTemplate.setName("Notification template: " + RandomStringUtils.randomAlphabetic(5)); + notificationTemplate.setNotificationType(notificationType); + NotificationTemplateConfig config = new NotificationTemplateConfig(); + config.setDeliveryMethodsTemplates(new HashMap<>()); + for (NotificationDeliveryMethod deliveryMethod : deliveryMethods) { + DeliveryMethodNotificationTemplate deliveryMethodNotificationTemplate; + switch (deliveryMethod) { + case WEB: { + deliveryMethodNotificationTemplate = new WebDeliveryMethodNotificationTemplate(); + break; + } + case EMAIL: { + deliveryMethodNotificationTemplate = new EmailDeliveryMethodNotificationTemplate(); + break; + } + case SMS: { + deliveryMethodNotificationTemplate = new SmsDeliveryMethodNotificationTemplate(); + break; + } + case MOBILE_APP: + deliveryMethodNotificationTemplate = new MobileAppDeliveryMethodNotificationTemplate(); + break; + default: + throw new IllegalArgumentException("Unsupported delivery method " + deliveryMethod); + } + deliveryMethodNotificationTemplate.setEnabled(true); + deliveryMethodNotificationTemplate.setBody(text); + if (deliveryMethodNotificationTemplate instanceof HasSubject) { + ((HasSubject) deliveryMethodNotificationTemplate).setSubject(subject); + } + config.getDeliveryMethodsTemplates().put(deliveryMethod, deliveryMethodNotificationTemplate); + } + notificationTemplate.setConfiguration(config); + return restClient.createNotificationTemplate(notificationTemplate); + } + + protected NotificationRequest submitNotificationRequest(NotificationTargetId targetId, NotificationTemplateId notificationTemplateId) { + NotificationRequestConfig config = new NotificationRequestConfig(); + config.setSendingDelayInSec(0); + NotificationRequest notificationRequest = NotificationRequest.builder() + .targets(List.of(targetId).stream().map(UUIDBased::getId).collect(Collectors.toList())) + .templateId(notificationTemplateId) + .additionalConfig(config) + .build(); + return restClient.createNotificationRequest(notificationRequest); } } diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 2c8e01d246..28304de049 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -144,6 +144,8 @@ import org.thingsboard.server.common.data.notification.NotificationRequestInfo; import org.thingsboard.server.common.data.notification.NotificationRequestPreview; import org.thingsboard.server.common.data.notification.settings.NotificationSettings; import org.thingsboard.server.common.data.notification.settings.UserNotificationSettings; +import org.thingsboard.server.common.data.notification.targets.NotificationTarget; +import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.common.data.oauth2.OAuth2ClientInfo; import org.thingsboard.server.common.data.oauth2.OAuth2ClientLoginInfo; @@ -1824,12 +1826,24 @@ public class RestClient implements Closeable { }).getBody(); } - public JsonNode findEntityTimeseriesAndAttributesKeysByQuery(EntityDataQuery query) { + public JsonNode findEntityTimeseriesAndAttributesKeysByQuery(EntityDataQuery query, boolean isTimeseries, boolean isAttributes, String scope) { + Map params = new HashMap<>(); + params.put("timeseries", String.valueOf(isTimeseries)); + params.put("attributes", String.valueOf(isAttributes)); + + StringBuilder urlBuilder = new StringBuilder(baseURL); + urlBuilder.append("/api/entitiesQuery/find/keys?timeseries={timeseries}&attributes={attributes}"); + + if (scope != null) { + urlBuilder.append("&scope={scope}"); + params.put("scope", scope); + } return restTemplate.exchange( - baseURL + "/api/entitiesQuery/find/keys", + urlBuilder.toString(), HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference() { - }).getBody(); + }, + params).getBody(); } public PageData findAlarmDataByQuery(AlarmDataQuery query) { @@ -2288,7 +2302,8 @@ public class RestClient implements Closeable { HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { - }).getBody(); + }, + params).getBody(); } public Optional getDomainInfoById(DomainId domainId) { @@ -2324,7 +2339,8 @@ public class RestClient implements Closeable { HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { - }).getBody(); + }, + params).getBody(); } public Optional getMobileAppById(MobileAppId mobileAppId) { @@ -2356,7 +2372,8 @@ public class RestClient implements Closeable { HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { - }).getBody(); + }, + params).getBody(); } public Optional getMobileBundleById(MobileAppBundleId mobileAppBundleId) { @@ -4283,11 +4300,23 @@ public class RestClient implements Closeable { } } - public PageData getNotifications(PageLink pageLink) { + public PageData getNotifications(Boolean unreadOnly, NotificationDeliveryMethod deliveryMethod, PageLink pageLink) { Map params = new HashMap<>(); + + StringBuilder urlBuilder = new StringBuilder(); + urlBuilder.append(baseURL).append("/api/notifications?").append(getUrlParams(pageLink)); addPageLinkToParam(params, pageLink); - return restTemplate.exchange( - baseURL + "/api/notifications?" + getUrlParams(pageLink), + + if (unreadOnly != null) { + urlBuilder.append("&unreadOnly={unreadOnly}"); + params.put("unreadOnly", unreadOnly.toString()); + } + if (deliveryMethod != null) { + urlBuilder.append("&deliveryMethod={deliveryMethod}"); + params.put("deliveryMethod", deliveryMethod.name()); + } + + return restTemplate.exchange(urlBuilder.toString(), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -4328,7 +4357,8 @@ public class RestClient implements Closeable { baseURL + uri, HttpMethod.PUT, HttpEntity.EMPTY, - Void.class); + Void.class, + params); } @@ -4415,6 +4445,14 @@ public class RestClient implements Closeable { } } + public NotificationTarget createNotificationTarget(NotificationTarget notificationTarget) { + return restTemplate.postForEntity(baseURL + "/api/notification/target", notificationTarget, NotificationTarget.class).getBody(); + } + + public NotificationTemplate createNotificationTemplate(NotificationTemplate notificationTemplate) { + return restTemplate.postForEntity(baseURL + "/api/notification/template", notificationTemplate, NotificationTemplate.class).getBody(); + } + public AiModel saveAiModel(AiModel aiModel) { return restTemplate.postForEntity(baseURL + "/api/ai/model", aiModel, AiModel.class).getBody(); } From 9da2b57a6c465b9540ac4c0c940dcfda108776c6 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 2 Dec 2025 16:27:14 +0200 Subject: [PATCH 2/8] fixed compilation after merge with master --- .../server/msa/connectivity/JavaRestClientTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index 7cee0a6ba6..f96f9aa953 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -80,6 +80,7 @@ import org.thingsboard.server.common.data.oauth2.PlatformType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.query.AvailableEntityKeys; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -225,9 +226,9 @@ public class JavaRestClientTest extends AbstractContainerTest { var entityFields = Arrays.asList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), new EntityKey(EntityKeyType.ENTITY_FIELD, "createdTime")); EntityDataQuery entityDataQuery = new EntityDataQuery(filter, pageLink, entityFields, null, null); - JsonNode result = restClient.findEntityTimeseriesAndAttributesKeysByQuery(entityDataQuery, true, true, null); - assertThat(result).isNotNull(); - assertThat((ArrayNode)result.get("timeseries")).contains(new TextNode("temperature"), new TextNode("humidity")); + AvailableEntityKeys availableEntityKeys = restClient.findAvailableEntityKeysByQuery(entityDataQuery, true, true, null); + assertThat(availableEntityKeys).isNotNull(); + assertThat(availableEntityKeys.timeseries()).contains("temperature", "humidity"); } @Test From bf4ffc8f9c7ae5b60e893cacdb46b3d24c6f9e7b Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 2 Dec 2025 16:50:18 +0200 Subject: [PATCH 3/8] refactoring --- .../msa/connectivity/JavaRestClientTest.java | 39 +++---------------- 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index f96f9aa953..9d0292cc7e 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.msa.connectivity; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.TextNode; import com.google.gson.JsonObject; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; @@ -67,7 +64,6 @@ import org.thingsboard.server.common.data.notification.settings.UserNotification import org.thingsboard.server.common.data.notification.targets.NotificationTarget; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter; -import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter; import org.thingsboard.server.common.data.notification.template.DeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.EmailDeliveryMethodNotificationTemplate; import org.thingsboard.server.common.data.notification.template.HasSubject; @@ -147,9 +143,7 @@ public class JavaRestClientTest extends AbstractContainerTest { tenant = restClient.saveTenant(tenant); String email = RandomStringUtils.randomAlphabetic(5) + "@gmail.com"; - user = restClient.saveUser(defaultTenantAdmin(tenant.getId(), email), false); - restClient.activateUser(user.getId(), "password123", false); restClient.login(email, "password123"); } @@ -306,26 +300,8 @@ public class JavaRestClientTest extends AbstractContainerTest { NotificationDeliveryMethod.EMAIL, false )); - var entitiesLimitNotificationPref = new UserNotificationSettings.NotificationPref(); - entitiesLimitNotificationPref.setEnabled(true); - entitiesLimitNotificationPref.setEnabledDeliveryMethods(Map.of( - NotificationDeliveryMethod.SMS, true, - NotificationDeliveryMethod.WEB, false, - NotificationDeliveryMethod.EMAIL, false - )); - - var apiUsageLimitNotificationPref = new UserNotificationSettings.NotificationPref(); - apiUsageLimitNotificationPref.setEnabled(false); - apiUsageLimitNotificationPref.setEnabledDeliveryMethods(Map.of( - NotificationDeliveryMethod.WEB, true, - NotificationDeliveryMethod.SMS, false, - NotificationDeliveryMethod.EMAIL, false - )); - UserNotificationSettings userNotificationSettings = new UserNotificationSettings(Map.of( - NotificationType.ENTITY_ACTION, entityActionNotificationPref, - NotificationType.ENTITIES_LIMIT, entitiesLimitNotificationPref, - NotificationType.API_USAGE_LIMIT, apiUsageLimitNotificationPref + NotificationType.ENTITY_ACTION, entityActionNotificationPref )); UserNotificationSettings saved = restClient.saveUserNotificationSettings(userNotificationSettings); UserNotificationSettings retrieved = restClient.getUserNotificationSettings().get(); @@ -376,22 +352,19 @@ public class JavaRestClientTest extends AbstractContainerTest { assertThat(bundleInfos).hasSize(1); } - protected NotificationTarget createNotificationTarget(UserId... usersIds) { + private NotificationTarget createNotificationTarget(UserId... usersIds) { UserListFilter filter = new UserListFilter(); filter.setUsersIds(Arrays.stream(usersIds).map(UUIDBased::getId).toList()); - return createNotificationTarget(filter); - } - protected NotificationTarget createNotificationTarget(UsersFilter usersFilter) { NotificationTarget notificationTarget = new NotificationTarget(); - notificationTarget.setName(usersFilter.toString() + org.apache.commons.lang3.RandomStringUtils.randomNumeric(5)); + notificationTarget.setName(filter.toString() + org.apache.commons.lang3.RandomStringUtils.randomNumeric(5)); PlatformUsersNotificationTargetConfig targetConfig = new PlatformUsersNotificationTargetConfig(); - targetConfig.setUsersFilter(usersFilter); + targetConfig.setUsersFilter(filter); notificationTarget.setConfiguration(targetConfig); return restClient.createNotificationTarget(notificationTarget); } - protected NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, + private NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, String text, NotificationDeliveryMethod... deliveryMethods) { NotificationTemplate notificationTemplate = new NotificationTemplate(); notificationTemplate.setName("Notification template: " + RandomStringUtils.randomAlphabetic(5)); @@ -430,7 +403,7 @@ public class JavaRestClientTest extends AbstractContainerTest { return restClient.createNotificationTemplate(notificationTemplate); } - protected NotificationRequest submitNotificationRequest(NotificationTargetId targetId, NotificationTemplateId notificationTemplateId) { + private NotificationRequest submitNotificationRequest(NotificationTargetId targetId, NotificationTemplateId notificationTemplateId) { NotificationRequestConfig config = new NotificationRequestConfig(); config.setSendingDelayInSec(0); NotificationRequest notificationRequest = NotificationRequest.builder() From ccde77036a2ee10f328ee8d3097af9f986e1828a Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 2 Dec 2025 16:55:32 +0200 Subject: [PATCH 4/8] refactoring --- .../msa/connectivity/JavaRestClientTest.java | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index 9d0292cc7e..d439bdcf48 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -318,9 +318,8 @@ public class JavaRestClientTest extends AbstractContainerTest { Domain savedDomain = restClient.saveDomain(domain); assertThat(savedDomain.getName()).isEqualTo(domain.getName()); - PageData tenantDomainInfos = restClient.getTenantDomainInfos(new PageLink(10)); - List domainInfos = tenantDomainInfos.getData().stream().filter(domainInfo -> domainInfo.getName().startsWith(prefix)).toList(); - assertThat(domainInfos).hasSize(1); + PageData domainInfos = restClient.getTenantDomainInfos(new PageLink(10, 0 , prefix)); + assertThat(domainInfos.getData()).hasSize(1); } @Test @@ -337,9 +336,8 @@ public class JavaRestClientTest extends AbstractContainerTest { MobileApp savedMobileApp = restClient.saveMobileApp(mobileApp); assertThat(savedMobileApp.getName()).isEqualTo(mobileApp.getName()); - PageData mobileApps = restClient.getTenantMobileApps(new PageLink(10)); - List retrieved = mobileApps.getData().stream().filter(app -> app.getPkgName().startsWith(prefix)).toList(); - assertThat(retrieved).hasSize(1); + PageData retrieved = restClient.getTenantMobileApps(new PageLink(10, 0, prefix)); + assertThat(retrieved.getData()).hasSize(1); MobileAppBundle mobileAppBundle = new MobileAppBundle(); String bundlePrefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); @@ -347,9 +345,8 @@ public class JavaRestClientTest extends AbstractContainerTest { mobileAppBundle.setAndroidAppId(savedMobileApp.getId()); MobileAppBundle savedMobileAppBundle = restClient.saveMobileBundle(mobileAppBundle); - PageData mobileBundleInfos = restClient.getTenantMobileBundleInfos(new PageLink(10)); - List bundleInfos = mobileBundleInfos.getData().stream().filter(mobileAppBundleInfo -> mobileAppBundleInfo.getTitle().startsWith(bundlePrefix)).toList(); - assertThat(bundleInfos).hasSize(1); + PageData bundleInfos = restClient.getTenantMobileBundleInfos(new PageLink(10, 0, bundlePrefix)); + assertThat(bundleInfos.getData()).hasSize(1); } private NotificationTarget createNotificationTarget(UserId... usersIds) { From 958e44f7d667ce697561f48509f41e372fc1c505 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Fri, 5 Dec 2025 14:14:17 +0200 Subject: [PATCH 5/8] set ts -1 when default value used --- ...faultCalculatedFieldProcessingService.java | 68 ++++------------- .../ctx/state/BaseCalculatedFieldState.java | 5 ++ .../ctx/state/SimpleCalculatedFieldState.java | 5 +- .../ctx/state/SingleValueArgumentEntry.java | 6 +- .../utils/CalculatedFieldArgumentUtils.java | 75 +++++++++++++++++++ .../cf/CalculatedFieldIntegrationTest.java | 17 ++++- .../script/api/tbel/TbelCfCtx.java | 2 +- 7 files changed, 112 insertions(+), 66 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index f2a6916751..9ff185bb52 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -23,7 +23,6 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.math.NumberUtils; import org.springframework.stereotype.Service; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; @@ -31,21 +30,14 @@ import org.thingsboard.server.actors.calculatedField.MultipleTbCallback; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.Aggregation; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; -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.KvEntry; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; -import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; @@ -70,9 +62,6 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; -import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; -import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; -import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry; import java.util.ArrayList; @@ -80,12 +69,16 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.DataConstants.SCOPE; +import static org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry.DEFAULT_TS; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultTsKvEntry; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument; import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; @TbRuleEngineComponent @@ -244,30 +237,17 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP private ListenableFuture fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) { return switch (argument.getRefEntityKey().getType()) { case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument); - case ATTRIBUTE -> transformSingleValueArgument( - Futures.transform( - attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()), - result -> result.or(() -> Optional.of(new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))), - calculatedFieldCallbackExecutor) - ); - case TS_LATEST -> transformSingleValueArgument( - Futures.transform( - timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()), - result -> result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))), - calculatedFieldCallbackExecutor)); + case ATTRIBUTE -> Futures.transform( + attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()), + result -> transformSingleValueArgument(result.orElseGet(() -> createDefaultAttributeEntry(argument, DEFAULT_TS))), + calculatedFieldCallbackExecutor); + case TS_LATEST -> Futures.transform( + timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()), + result -> transformSingleValueArgument(result.orElseGet(() -> createDefaultTsKvEntry(argument, DEFAULT_TS))), + calculatedFieldCallbackExecutor); }; } - private ListenableFuture transformSingleValueArgument(ListenableFuture> kvEntryFuture) { - return Futures.transform(kvEntryFuture, kvEntry -> { - if (kvEntry.isPresent() && kvEntry.get().getValue() != null) { - return ArgumentEntry.createSingleValueArgument(kvEntry.get()); - } else { - return new SingleValueArgumentEntry(); - } - }, calculatedFieldCallbackExecutor); - } - private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument) { long currentTime = System.currentTimeMillis(); long timeWindow = argument.getTimeWindow() == 0 ? System.currentTimeMillis() : argument.getTimeWindow(); @@ -282,28 +262,6 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP return Futures.transform(tsRollingFuture, tsRolling -> tsRolling == null ? new TsRollingArgumentEntry(limit, timeWindow) : ArgumentEntry.createTsRollingArgument(tsRolling, limit, timeWindow), calculatedFieldCallbackExecutor); } - private KvEntry createDefaultKvEntry(Argument argument) { - String key = argument.getRefEntityKey().getKey(); - String defaultValue = argument.getDefaultValue(); - if (StringUtils.isBlank(defaultValue)) { - return new StringDataEntry(key, null); - } - if (NumberUtils.isParsable(defaultValue)) { - return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); - } - if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { - return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); - } - return new StringDataEntry(key, defaultValue); - } - - private CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { - return switch (ctx.getCfType()) { - case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); - case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); - }; - } - private static class TbCallbackWrapper implements TbQueueCallback { private final TbCallback callback; diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 94a18256a3..856081d7c5 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -48,6 +48,11 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { this(new ArrayList<>(), new HashMap<>(), false, DEFAULT_LAST_UPDATE_TS); } + + public long getLatestTimestamp() { + return latestTimestamp == DEFAULT_LAST_UPDATE_TS ? System.currentTimeMillis() : latestTimestamp; + } + @Override public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) { if (arguments == null) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index 80f5964582..e80939a952 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -99,10 +99,9 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { valuesNode.set(outputName, JacksonUtil.valueToTree(result)); } - long latestTs = getLatestTimestamp(); - if (useLatestTs && latestTs != DEFAULT_LAST_UPDATE_TS) { + if (useLatestTs) { ObjectNode resultNode = JacksonUtil.newObjectNode(); - resultNode.put("ts", latestTs); + resultNode.put("ts", getLatestTimestamp()); resultNode.set("values", valuesNode); return resultNode; } else { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java index 0997fd6cbb..b26e53ef3c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.type.TypeReference; import lombok.AllArgsConstructor; import lombok.Data; -import lombok.NoArgsConstructor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; @@ -32,13 +31,12 @@ import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; -import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState.DEFAULT_LAST_UPDATE_TS; - @Data @AllArgsConstructor public class SingleValueArgumentEntry implements ArgumentEntry { public static final Long DEFAULT_VERSION = -1L; + public static final Long DEFAULT_TS = -1L; private long ts; private BasicKvEntry kvEntryValue; @@ -47,7 +45,7 @@ public class SingleValueArgumentEntry implements ArgumentEntry { private boolean forceResetPrevious; public SingleValueArgumentEntry() { - this.ts = DEFAULT_LAST_UPDATE_TS; + this.ts = DEFAULT_TS; this.version = DEFAULT_VERSION; } diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java new file mode 100644 index 0000000000..1296df6e87 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java @@ -0,0 +1,75 @@ +/** + * 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.utils; + +import lombok.NonNull; +import org.apache.commons.lang3.math.NumberUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +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.KvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import static org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry.DEFAULT_VERSION; + +public class CalculatedFieldArgumentUtils { + + public static ArgumentEntry transformSingleValueArgument(@NonNull KvEntry kvEntry) { + return kvEntry.getValue() != null ? ArgumentEntry.createSingleValueArgument(kvEntry) : new SingleValueArgumentEntry(); + } + + public static TsKvEntry createDefaultTsKvEntry(Argument argument, long ts) { + return new BasicTsKvEntry(ts, createDefaultKvEntry(argument), DEFAULT_VERSION); + } + + public static AttributeKvEntry createDefaultAttributeEntry(Argument argument, long ts) { + return new BaseAttributeKvEntry(createDefaultKvEntry(argument), ts, DEFAULT_VERSION); + } + + private static KvEntry createDefaultKvEntry(Argument argument) { + String key = argument.getRefEntityKey().getKey(); + String defaultValue = argument.getDefaultValue(); + if (StringUtils.isBlank(defaultValue)) { + return new StringDataEntry(key, null); + } + if (NumberUtils.isParsable(defaultValue)) { + return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); + } + if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { + return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); + } + return new StringDataEntry(key, defaultValue); + } + + public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { + return switch (ctx.getCfType()) { + case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); + case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); + }; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index b500f95d45..03052382f0 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -570,8 +570,6 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes @Test public void testScriptCalculatedFieldWhenUsedLatestTsInScript() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); - long ts = System.currentTimeMillis() - 300000L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); @@ -585,6 +583,7 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes Argument argument = new Argument(); ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); argument.setRefEntityKey(refEntityKey); + argument.setDefaultValue("20"); config.setArguments(Map.of("T", argument)); config.setExpression("return {\"ts\": ctx.latestTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};"); @@ -596,7 +595,19 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); - await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + await().alias("create CF -> perform initial calculation with default value").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("ts").asText()).isNotEqualTo("-1"); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("68.0"); + }); + + long ts = System.currentTimeMillis() - 10L; + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + + await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java index c6023154ea..2fe861ba81 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java @@ -29,7 +29,7 @@ public class TbelCfCtx implements TbelCfObject { public TbelCfCtx(Map args, long latestTs) { this.args = Collections.unmodifiableMap(args); - this.latestTs = latestTs != -1 ? latestTs : System.currentTimeMillis(); + this.latestTs = latestTs; } @Override From 4e2b4fc9216b554343704fa5b4ddb9d5113b5710 Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 8 Dec 2025 10:08:18 +0200 Subject: [PATCH 6/8] added getLatestTs method and added tests --- ...faultCalculatedFieldProcessingService.java | 5 +- .../service/cf/ctx/state/ArgumentEntry.java | 2 + .../ctx/state/BaseCalculatedFieldState.java | 22 ++--- .../ctx/state/SimpleCalculatedFieldState.java | 5 +- .../ctx/state/SingleValueArgumentEntry.java | 17 +++- .../cf/ctx/state/TsRollingArgumentEntry.java | 8 ++ .../cf/CalculatedFieldIntegrationTest.java | 98 ++++++++++++++++--- .../script/api/tbel/TbelCfCtx.java | 2 +- 8 files changed, 124 insertions(+), 35 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 9ff185bb52..b6e1193cfb 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -74,7 +74,6 @@ import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.DataConstants.SCOPE; -import static org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry.DEFAULT_TS; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultTsKvEntry; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; @@ -239,11 +238,11 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument); case ATTRIBUTE -> Futures.transform( attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()), - result -> transformSingleValueArgument(result.orElseGet(() -> createDefaultAttributeEntry(argument, DEFAULT_TS))), + result -> transformSingleValueArgument(result.orElseGet(() -> createDefaultAttributeEntry(argument, System.currentTimeMillis()))), calculatedFieldCallbackExecutor); case TS_LATEST -> Futures.transform( timeseriesService.findLatest(tenantId, entityId, argument.getRefEntityKey().getKey()), - result -> transformSingleValueArgument(result.orElseGet(() -> createDefaultTsKvEntry(argument, DEFAULT_TS))), + result -> transformSingleValueArgument(result.orElseGet(() -> createDefaultTsKvEntry(argument, System.currentTimeMillis()))), calculatedFieldCallbackExecutor); }; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index 83e10b8194..3df43d8c2b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -40,6 +40,8 @@ public interface ArgumentEntry { Object getValue(); + long getLatestTs(); + boolean updateEntry(ArgumentEntry entry); boolean isEmpty(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 856081d7c5..b6d4ad0fac 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -48,11 +48,6 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { this(new ArrayList<>(), new HashMap<>(), false, DEFAULT_LAST_UPDATE_TS); } - - public long getLatestTimestamp() { - return latestTimestamp == DEFAULT_LAST_UPDATE_TS ? System.currentTimeMillis() : latestTimestamp; - } - @Override public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) { if (arguments == null) { @@ -80,7 +75,6 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { if (entryUpdated) { stateUpdated = true; - updateLastUpdateTimestamp(newEntry); } } @@ -116,15 +110,13 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { protected abstract void validateNewEntry(ArgumentEntry newEntry); - private void updateLastUpdateTimestamp(ArgumentEntry entry) { - long newTs = this.latestTimestamp; - if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { - newTs = singleValueArgumentEntry.getTs(); - } else if (entry instanceof TsRollingArgumentEntry tsRollingArgumentEntry) { - Map.Entry lastEntry = tsRollingArgumentEntry.getTsRecords().lastEntry(); - newTs = (lastEntry != null) ? lastEntry.getKey() : DEFAULT_LAST_UPDATE_TS; - } - this.latestTimestamp = Math.max(this.latestTimestamp, newTs); + public long getLatestTimestamp() { + long currentLatestTs = arguments.values().stream() + .mapToLong(ArgumentEntry::getLatestTs) + .max() + .orElse(DEFAULT_LAST_UPDATE_TS); + latestTimestamp = Math.max(currentLatestTs, latestTimestamp); + return latestTimestamp; } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java index e80939a952..80f5964582 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldState.java @@ -99,9 +99,10 @@ public class SimpleCalculatedFieldState extends BaseCalculatedFieldState { valuesNode.set(outputName, JacksonUtil.valueToTree(result)); } - if (useLatestTs) { + long latestTs = getLatestTimestamp(); + if (useLatestTs && latestTs != DEFAULT_LAST_UPDATE_TS) { ObjectNode resultNode = JacksonUtil.newObjectNode(); - resultNode.put("ts", getLatestTimestamp()); + resultNode.put("ts", latestTs); resultNode.set("values", valuesNode); return resultNode; } else { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java index b26e53ef3c..186af92f38 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -31,12 +31,13 @@ import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos.AttributeValueProto; import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; +import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState.DEFAULT_LAST_UPDATE_TS; + @Data @AllArgsConstructor public class SingleValueArgumentEntry implements ArgumentEntry { public static final Long DEFAULT_VERSION = -1L; - public static final Long DEFAULT_TS = -1L; private long ts; private BasicKvEntry kvEntryValue; @@ -45,7 +46,7 @@ public class SingleValueArgumentEntry implements ArgumentEntry { private boolean forceResetPrevious; public SingleValueArgumentEntry() { - this.ts = DEFAULT_TS; + this.ts = DEFAULT_LAST_UPDATE_TS; this.version = DEFAULT_VERSION; } @@ -97,6 +98,11 @@ public class SingleValueArgumentEntry implements ArgumentEntry { return isEmpty() ? null : kvEntryValue.getValue(); } + @Override + public long getLatestTs() { + return !isDefaultValue() ? ts : DEFAULT_LAST_UPDATE_TS; + } + @Override public TbelCfArg toTbelCfArg() { Object value = kvEntryValue.getValue(); @@ -118,7 +124,7 @@ public class SingleValueArgumentEntry implements ArgumentEntry { @Override public boolean updateEntry(ArgumentEntry entry) { if (entry instanceof SingleValueArgumentEntry singleValueEntry) { - if (singleValueEntry.getTs() <= this.ts) { + if (singleValueEntry.getTs() < this.ts) { return false; } @@ -134,4 +140,9 @@ public class SingleValueArgumentEntry implements ArgumentEntry { } return false; } + + public boolean isDefaultValue() { + return DEFAULT_VERSION.equals(this.version); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java index b5a680a072..ada46a841e 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java @@ -31,6 +31,8 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import static org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState.DEFAULT_LAST_UPDATE_TS; + @Data @NoArgsConstructor @AllArgsConstructor @@ -83,6 +85,12 @@ public class TsRollingArgumentEntry implements ArgumentEntry { return tsRecords; } + @Override + public long getLatestTs() { + var lastEntry = tsRecords.lastEntry(); + return (lastEntry != null) ? lastEntry.getKey() : DEFAULT_LAST_UPDATE_TS; + } + @Override public TbelCfArg toTbelCfArg() { List values = new ArrayList<>(tsRecords.size()); diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 03052382f0..c83e8c5ee3 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -45,6 +45,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DaoSqlTest public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTest { @@ -571,6 +572,9 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes public void testScriptCalculatedFieldWhenUsedLatestTsInScript() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); + long ts = System.currentTimeMillis() - 300000L; + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + CalculatedField calculatedField = new CalculatedField(); calculatedField.setEntityId(testDevice.getId()); calculatedField.setType(CalculatedFieldType.SCRIPT); @@ -583,7 +587,6 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes Argument argument = new Argument(); ReferencedEntityKey refEntityKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); argument.setRefEntityKey(refEntityKey); - argument.setDefaultValue("20"); config.setArguments(Map.of("T", argument)); config.setExpression("return {\"ts\": ctx.latestTs, \"values\": {\"fahrenheitTemp\": (T * 1.8) + 32}};"); @@ -595,25 +598,98 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); - await().alias("create CF -> perform initial calculation with default value").atMost(TIMEOUT, TimeUnit.SECONDS) + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); assertThat(fahrenheitTemp).isNotNull(); - assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("ts").asText()).isNotEqualTo("-1"); - assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("68.0"); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); }); + } - long ts = System.currentTimeMillis() - 10L; - doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"temperature\":30}}", ts))); + @Test + public void testSimpleCalculatedFieldWhenUseLatestTsIsTrueAndDefaultArguments() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); - await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("a + b + c"); + calculatedField.setDebugSettings(DebugSettings.all()); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument argument1 = new Argument(); + ReferencedEntityKey refEntityKey1 = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null); + argument1.setRefEntityKey(refEntityKey1); + argument1.setDefaultValue("100"); + Argument argument2 = new Argument(); + ReferencedEntityKey refEntityKey2 = new ReferencedEntityKey("b", ArgumentType.TS_LATEST, null); + argument2.setRefEntityKey(refEntityKey2); + argument2.setDefaultValue("200"); + Argument argument3 = new Argument(); + ReferencedEntityKey refEntityKey3 = new ReferencedEntityKey("c", ArgumentType.TS_LATEST, null); + argument3.setRefEntityKey(refEntityKey3); + argument3.setDefaultValue("300"); + config.setArguments(Map.of("a", argument1, "b", argument2, "c", argument3)); + config.setExpression("a + b + c"); + + Output output = new Output(); + output.setName("d"); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + config.setOutput(output); + + config.setUseLatestTs(true); + + calculatedField.setConfiguration(config); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + await().alias("create CF -> perform initial calculation with default arguments").atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) .untilAsserted(() -> { - ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "fahrenheitTemp"); - assertThat(fahrenheitTemp).isNotNull(); - assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("ts").asText()).isEqualTo(Long.toString(ts)); - assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + ObjectNode d = getLatestTelemetry(testDevice.getId(), "d"); + assertThat(d).isNotNull(); + assertThat(d.get("d").get(0).get("value").asText()).isEqualTo("600"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":10}")); + + await().alias("update telemetry -> save result with ts of 'a' argument").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode keys = getLatestTelemetry(testDevice.getId(), "d", "a"); + assertThat(keys).isNotNull(); + String aTs = keys.get("a").get(0).get("ts").asText(); + assertThat(keys.get("d").get(0).get("ts").asText()).isEqualTo(aTs); + assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("510"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"b\":20}")); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"c\":30}")); + + await().alias("update telemetry -> save result with latest ts of updated arguments").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode keys = getLatestTelemetry(testDevice.getId(), "d"); + assertThat(keys).isNotNull(); + assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("60"); + }); + + String latestTs = getLatestTelemetry(testDevice.getId(), "d").get("d").get(0).get("ts").asText(); + + doDelete("/api/plugins/telemetry/DEVICE/" + testDevice.getId() + "/timeseries/delete?keys=b&deleteAllDataForKeys=true").andExpect(status().isOk()); + + await().alias("delete telemetry -> save result with previous latest ts and default argument").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode keys = getLatestTelemetry(testDevice.getId(), "d"); + assertThat(keys).isNotNull(); + assertThat(keys.get("d").get(0).get("ts").asText()).isEqualTo(latestTs); + assertThat(keys.get("d").get(0).get("value").asText()).isEqualTo("240"); }); } diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java index 2fe861ba81..c6023154ea 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfCtx.java @@ -29,7 +29,7 @@ public class TbelCfCtx implements TbelCfObject { public TbelCfCtx(Map args, long latestTs) { this.args = Collections.unmodifiableMap(args); - this.latestTs = latestTs; + this.latestTs = latestTs != -1 ? latestTs : System.currentTimeMillis(); } @Override From 6f17dd1503ba2c221e5eab424f55c4897a6ae8c0 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 8 Dec 2025 11:10:41 +0200 Subject: [PATCH 7/8] RestClient: renamed methods --- .../server/msa/connectivity/JavaRestClientTest.java | 6 +++--- .../main/java/org/thingsboard/rest/client/RestClient.java | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index d439bdcf48..fb41cdf77f 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -358,7 +358,7 @@ public class JavaRestClientTest extends AbstractContainerTest { PlatformUsersNotificationTargetConfig targetConfig = new PlatformUsersNotificationTargetConfig(); targetConfig.setUsersFilter(filter); notificationTarget.setConfiguration(targetConfig); - return restClient.createNotificationTarget(notificationTarget); + return restClient.saveNotificationTarget(notificationTarget); } private NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, @@ -397,7 +397,7 @@ public class JavaRestClientTest extends AbstractContainerTest { config.getDeliveryMethodsTemplates().put(deliveryMethod, deliveryMethodNotificationTemplate); } notificationTemplate.setConfiguration(config); - return restClient.createNotificationTemplate(notificationTemplate); + return restClient.saveNotificationTemplate(notificationTemplate); } private NotificationRequest submitNotificationRequest(NotificationTargetId targetId, NotificationTemplateId notificationTemplateId) { @@ -408,6 +408,6 @@ public class JavaRestClientTest extends AbstractContainerTest { .templateId(notificationTemplateId) .additionalConfig(config) .build(); - return restClient.createNotificationRequest(notificationRequest); + return restClient.saveNotificationRequest(notificationRequest); } } diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 64fd891f2c..c82a80e7cc 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -4360,7 +4360,7 @@ public class RestClient implements Closeable { restTemplate.delete(baseURL + "/api/notification/{id}", notificationId.getId()); } - public NotificationRequest createNotificationRequest(NotificationRequest notificationRequest) { + public NotificationRequest saveNotificationRequest(NotificationRequest notificationRequest) { return restTemplate.postForEntity(baseURL + "/api/notification/request", notificationRequest, NotificationRequest.class).getBody(); } @@ -4439,11 +4439,11 @@ public class RestClient implements Closeable { } } - public NotificationTarget createNotificationTarget(NotificationTarget notificationTarget) { + public NotificationTarget saveNotificationTarget(NotificationTarget notificationTarget) { return restTemplate.postForEntity(baseURL + "/api/notification/target", notificationTarget, NotificationTarget.class).getBody(); } - public NotificationTemplate createNotificationTemplate(NotificationTemplate notificationTemplate) { + public NotificationTemplate saveNotificationTemplate(NotificationTemplate notificationTemplate) { return restTemplate.postForEntity(baseURL + "/api/notification/template", notificationTemplate, NotificationTemplate.class).getBody(); } From 0577d66dcad395f3c82a1e8a547e54df1f5a5b9b Mon Sep 17 00:00:00 2001 From: IrynaMatveieva Date: Mon, 8 Dec 2025 12:37:20 +0200 Subject: [PATCH 8/8] return the most recent ts of default args if all are default --- .../service/cf/ctx/state/ArgumentEntry.java | 2 -- .../ctx/state/BaseCalculatedFieldState.java | 32 +++++++++++++------ .../ctx/state/SingleValueArgumentEntry.java | 5 --- .../cf/ctx/state/TsRollingArgumentEntry.java | 1 - 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index 3df43d8c2b..83e10b8194 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -40,8 +40,6 @@ public interface ArgumentEntry { Object getValue(); - long getLatestTs(); - boolean updateEntry(ArgumentEntry entry); boolean isEmpty(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index b6d4ad0fac..14370aa68c 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -37,15 +37,13 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { protected Map arguments; protected boolean sizeExceedsLimit; - protected long latestTimestamp = DEFAULT_LAST_UPDATE_TS; - public BaseCalculatedFieldState(List requiredArguments) { this.requiredArguments = requiredArguments; this.arguments = new HashMap<>(); } public BaseCalculatedFieldState() { - this(new ArrayList<>(), new HashMap<>(), false, DEFAULT_LAST_UPDATE_TS); + this(new ArrayList<>(), new HashMap<>(), false); } @Override @@ -111,12 +109,28 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState { protected abstract void validateNewEntry(ArgumentEntry newEntry); public long getLatestTimestamp() { - long currentLatestTs = arguments.values().stream() - .mapToLong(ArgumentEntry::getLatestTs) - .max() - .orElse(DEFAULT_LAST_UPDATE_TS); - latestTimestamp = Math.max(currentLatestTs, latestTimestamp); - return latestTimestamp; + long latestTs = DEFAULT_LAST_UPDATE_TS; + + boolean allDefault = arguments.values().stream().allMatch(entry -> { + if (entry instanceof SingleValueArgumentEntry single) { + return single.isDefaultValue(); + } + return false; + }); + + for (ArgumentEntry entry : arguments.values()) { + if (entry instanceof SingleValueArgumentEntry single) { + if (allDefault) { + latestTs = Math.max(latestTs, single.getTs()); + } else if (!single.isDefaultValue()) { + latestTs = Math.max(latestTs, single.getTs()); + } + } else if (entry instanceof TsRollingArgumentEntry rolling) { + latestTs = Math.max(latestTs, rolling.getLatestTs()); + } + } + + return latestTs; } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java index 186af92f38..2f9a7de940 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/SingleValueArgumentEntry.java @@ -98,11 +98,6 @@ public class SingleValueArgumentEntry implements ArgumentEntry { return isEmpty() ? null : kvEntryValue.getValue(); } - @Override - public long getLatestTs() { - return !isDefaultValue() ? ts : DEFAULT_LAST_UPDATE_TS; - } - @Override public TbelCfArg toTbelCfArg() { Object value = kvEntryValue.getValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java index ada46a841e..e01d8b7369 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/TsRollingArgumentEntry.java @@ -85,7 +85,6 @@ public class TsRollingArgumentEntry implements ArgumentEntry { return tsRecords; } - @Override public long getLatestTs() { var lastEntry = tsRecords.lastEntry(); return (lastEntry != null) ? lastEntry.getKey() : DEFAULT_LAST_UPDATE_TS;