Browse Source

Merge remote-tracking branch 'thingsboard-ce/master' into feat/new-control-syntax

pull/15306/head
Maksym Tsymbarov 7 months ago
parent
commit
c2ec2ed561
  1. 5
      application/pom.xml
  2. 18
      application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java
  3. 85
      application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java
  4. 712
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java
  5. 1029
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java
  6. 1
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java
  7. 57
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java
  8. 238
      application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java
  9. 131
      application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java
  10. 186
      application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java
  11. 46
      application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java
  12. 23
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java
  13. 218
      application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java
  14. 392
      application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java
  15. 131
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java
  16. 16
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java
  17. 621
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java
  18. 34
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java
  19. 43
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java
  20. 83
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java
  21. 23
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java
  22. 149
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java
  23. 47
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java
  24. 302
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java
  25. 183
      application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java
  26. 9
      application/src/main/resources/thingsboard.yml
  27. 168
      application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java
  28. 127
      application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java
  29. 99
      application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java
  30. 163
      application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java
  31. 106
      application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java
  32. 117
      application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java
  33. 84
      application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java
  34. 135
      application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java
  35. 286
      application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java
  36. 113
      application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java
  37. 123
      application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java
  38. 114
      application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java
  39. 53
      application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java
  40. 157
      application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java
  41. 105
      application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java
  42. 141
      application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java
  43. 289
      application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java
  44. 182
      application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java
  45. 267
      application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java
  46. 156
      application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java
  47. 278
      application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java
  48. 138
      application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java
  49. 261
      application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java
  50. 72
      application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java
  51. 133
      application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java
  52. 163
      application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java
  53. 151
      application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java
  54. 128
      application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java
  55. 150
      application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java
  56. 119
      application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java
  57. 179
      application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java
  58. 78
      application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java
  59. 135
      application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java
  60. 155
      application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java
  61. 12
      application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java
  62. 3
      application/src/test/resources/logback-test.xml
  63. 18
      common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java
  64. 6
      common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java
  65. 2
      common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java
  66. 69
      pom.xml
  67. 4
      rule-engine/rule-engine-components/pom.xml
  68. 62
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java
  69. 32
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java
  70. 38
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java
  71. 2
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java
  72. 504
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java
  73. 178
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java
  74. 4
      ui-ngx/src/app/core/interceptors/global-http-interceptor.ts
  75. 18
      ui-ngx/src/app/core/utils.ts
  76. 3
      ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts
  77. 59
      ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html
  78. 84
      ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts
  79. 21
      ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html
  80. 10
      ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts
  81. 266
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html
  82. 29
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss
  83. 33
      ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts
  84. 24
      ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts
  85. 3
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts
  86. 2
      ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts
  87. 9
      ui-ngx/src/app/shared/components/string-items-list.component.html
  88. 45
      ui-ngx/src/app/shared/components/string-items-list.component.ts
  89. 6
      ui-ngx/src/app/shared/models/constants.ts
  90. 13
      ui-ngx/src/assets/locale/locale.constant-en_US.json
  91. 9445
      ui-ngx/src/assets/locale/locale.constant-ko_KR.json
  92. 746
      ui-ngx/structure.md
  93. 6
      ui-ngx/yarn.lock

5
application/pom.xml

@ -290,6 +290,11 @@
<artifactId>rest-client</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.thingsboard.client</groupId>
<artifactId>thingsboard-ce-client</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>

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

@ -15,11 +15,14 @@
*/
package org.thingsboard.server.service.edge;
import jakarta.annotation.PreDestroy;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.edge.EdgeEventType;
@ -86,6 +89,8 @@ import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@Lazy
@Data
@ -96,14 +101,25 @@ public class EdgeContextComponent {
private final Map<EdgeEventType, EdgeProcessor> processorMap = new EnumMap<>(EdgeEventType.class);
private ScheduledExecutorService edgeEventProcessingExecutorService;
@Autowired
public EdgeContextComponent(List<EdgeProcessor> processors) {
public EdgeContextComponent(List<EdgeProcessor> processors,
@Value("${edges.scheduler_pool_size}") int schedulerPoolSize) {
processors.forEach(processor -> {
EdgeEventType eventType = processor.getEdgeEventType();
if (eventType != null) {
processorMap.put(eventType, processor);
}
});
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
}
@PreDestroy
private void destroy() {
if (edgeEventProcessingExecutorService != null && !edgeEventProcessingExecutorService.isShutdown()) {
edgeEventProcessingExecutorService.shutdownNow();
}
}
// services

85
application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java

@ -0,0 +1,85 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.edge.EdgeEventType;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.utils.EdgeVersionUtils;
import java.util.ArrayList;
import java.util.List;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class DownlinkMessageMapper {
private final EdgeContextComponent ctx;
public List<DownlinkMsg> convertToDownlinkMsgsPack(EdgeSessionState state, List<EdgeEvent> edgeEvents) {
List<DownlinkMsg> result = new ArrayList<>();
List<EdgeEvent> filtered = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(edgeEvents);
for (EdgeEvent edgeEvent : filtered) {
log.trace("[{}][{}] converting edge event to downlink msg [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent);
DownlinkMsg downlinkMsg = null;
try {
switch (edgeEvent.getAction()) {
case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR,
ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, RPC_CALL,
ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> {
downlinkMsg = convertEntityEventToDownlink(state, edgeEvent);
if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) {
log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}",
state.getTenantId(), state.getEdgeId(), downlinkMsg.getDownlinkMsgId());
} else {
log.trace("[{}][{}] entity message processed [{}]", state.getTenantId(), state.getEdgeId(), downlinkMsg);
}
}
case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED ->
downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(state.getEdge(), edgeEvent);
default -> log.warn("[{}][{}] Unsupported action type [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent.getAction());
}
} catch (Exception e) {
log.trace("[{}][{}] Exception during converting edge event to downlink msg", state.getTenantId(), state.getEdgeId(), e);
}
if (downlinkMsg != null) {
result.add(downlinkMsg);
}
}
return result;
}
protected DownlinkMsg convertEntityEventToDownlink(EdgeSessionState state, EdgeEvent edgeEvent) {
log.trace("[{}] Executing convertEntityEventToDownlink, edgeEvent [{}], action [{}]", edgeEvent.getTenantId(), edgeEvent, edgeEvent.getAction());
if ((EdgeEventType.OAUTH2_CLIENT.equals(edgeEvent.getType()) || EdgeEventType.DOMAIN.equals(edgeEvent.getType())) &&
(EdgeVersionUtils.isEdgeVersionOlderThan(state.getEdgeVersion(), EdgeVersion.V_3_8_0))) {
return null;
}
return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, state.getEdgeVersion());
}
}

712
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java

@ -1,712 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.Nullable;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.function.Function;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME;
@Service
@Slf4j
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
private final ConcurrentMap<EdgeId, EdgeGrpcSession> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, EdgeGrpcSession> sessionsById = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Lock> sessionNewEventsLocks = new ConcurrentHashMap<>();
private final Map<EdgeId, Boolean> sessionNewEvents = new HashMap<>();
private final ConcurrentMap<EdgeId, ScheduledFuture<?>> sessionEdgeEventChecks = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private final ConcurrentMap<EdgeId, Boolean> edgeEventsMigrationProcessed = new ConcurrentHashMap<>();
private final List<EdgeGrpcSession> zombieSessions = new ArrayList<>();
@Value("${edges.rpc.port}")
private int rpcPort;
@Value("${edges.rpc.ssl.enabled}")
private boolean sslEnabled;
@Value("${edges.rpc.ssl.cert}")
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
private int clientMaxKeepAliveTimeSec;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
private int maxInboundMessageSize;
@Value("${edges.rpc.keep_alive_time_sec:10}")
private int keepAliveTimeSec;
@Value("${edges.rpc.keep_alive_timeout_sec:5}")
private int keepAliveTimeoutSec;
@Value("${edges.scheduler_pool_size}")
private int schedulerPoolSize;
@Value("${edges.send_scheduler_pool_size}")
private int sendSchedulerPoolSize;
@Value("${edges.max_high_priority_queue_size_per_session:10000}")
private int maxHighPriorityQueueSizePerSession;
@Autowired
@Lazy
private EdgeContextComponent ctx;
@Autowired
private TbClusterService clusterService;
@Autowired
private TbServiceInfoProvider serviceInfoProvider;
@Autowired
private TbTransactionalCache<EdgeId, String> edgeIdServiceIdCache;
@Autowired
private TopicService topicService;
@Autowired
private TbCoreQueueFactory tbCoreQueueFactory;
@Autowired
private Optional<KafkaAdmin> kafkaAdmin;
private Server server;
private ScheduledExecutorService edgeEventProcessingExecutorService;
private ScheduledExecutorService sendDownlinkExecutorService;
private ScheduledExecutorService executorService;
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
public void onStartUp() {
log.info("Initializing Edge RPC service!");
NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort)
.permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS)
.permitKeepAliveWithoutCalls(true)
.maxInboundMessageSize(maxInboundMessageSize)
.addService(this);
if (sslEnabled) {
try {
setupSsl(builder);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e);
}
}
server = builder.build();
log.info("Going to start Edge RPC server using port: {}", rpcPort);
try {
server.start();
} catch (IOException e) {
log.error("Failed to start Edge RPC server!", e);
throw new RuntimeException("Failed to start Edge RPC server!");
}
this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS);
log.info("Edge RPC service initialized!");
}
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) throws Exception {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
}
@PreDestroy
public void destroy() {
if (server != null) {
server.shutdownNow();
}
for (Map.Entry<EdgeId, ScheduledFuture<?>> entry : sessionEdgeEventChecks.entrySet()) {
EdgeId edgeId = entry.getKey();
ScheduledFuture<?> sessionEdgeEventCheck = entry.getValue();
if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) {
sessionEdgeEventCheck.cancel(true);
sessionEdgeEventChecks.remove(edgeId);
}
}
if (edgeEventProcessingExecutorService != null) {
edgeEventProcessingExecutorService.shutdownNow();
}
if (sendDownlinkExecutorService != null) {
sendDownlinkExecutorService.shutdownNow();
}
if (executorService != null) {
executorService.shutdownNow();
}
}
@Override
public StreamObserver<RequestMsg> handleMsgs(StreamObserver<ResponseMsg> outputStream) {
EdgeGrpcSession session = createEdgeGrpcSession(outputStream);
return session.getInputStream();
}
private EdgeGrpcSession createEdgeGrpcSession(StreamObserver<ResponseMsg> outputStream) {
return kafkaAdmin.isPresent()
? new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin.get(), outputStream, this::onEdgeConnect, this::onEdgeDisconnect,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession)
: new PostgresEdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
}
@Override
public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) {
switch (msg.getMsgType()) {
case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> {
EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg;
log.trace("[{}] edgeEventMsg [{}]", tenantId, msg);
onEdgeHighPriorityEvent(edgeHighPriorityMsg);
}
case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> {
EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg;
log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg);
onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId());
}
case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> {
ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg;
log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg);
startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId());
}
case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> {
FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg;
log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg);
processSyncResponse(fromEdgeSyncResponse);
}
}
}
@Override
public void updateEdge(TenantId tenantId, Edge edge) {
if (edge == null) {
log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId);
return;
}
EdgeGrpcSession session = sessions.get(edge.getId());
if (session != null && session.isConnected()) {
log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
session.onConfigurationUpdate(edge);
} else {
log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
}
}
@Override
public void deleteEdge(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId);
destroySession(session);
session.cleanUp();
sessions.remove(edgeId);
sessionsById.remove(session.getSessionId());
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.remove(edgeId);
} finally {
newEventLock.unlock();
}
cancelScheduleEdgeEventsCheck(edgeId);
}
}
private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId());
updateSessionEventsFlag(tenantId, edgeId);
}
}
private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) {
TenantId tenantId = msg.getTenantId();
EdgeEvent edgeEvent = msg.getEdgeEvent();
EdgeId edgeId = edgeEvent.getEdgeId();
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null && session.isConnected()) {
log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId);
session.addEventToHighPriorityQueue(edgeEvent);
updateSessionEventsFlag(tenantId, edgeId);
}
}
private void updateSessionEventsFlag(TenantId tenantId, EdgeId edgeId) {
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
if (Boolean.FALSE.equals(sessionNewEvents.get(edgeId))) {
log.trace("[{}] set session new events flag to true [{}]", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, true);
}
} finally {
newEventLock.unlock();
}
}
private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSession edgeGrpcSession) {
Edge edge = edgeGrpcSession.getEdge();
TenantId tenantId = edge.getTenantId();
log.info("[{}][{}] edge [{}] connected successfully.", tenantId, edgeGrpcSession.getSessionId(), edgeId);
if (sessions.containsKey(edgeId)) {
EdgeGrpcSession existing = sessions.get(edgeId);
if (existing != null) {
log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, edgeGrpcSession.getSessionId(), existing.getSessionId(), edgeId);
destroySession(existing);
sessionsById.remove(existing.getSessionId());
}
}
sessions.put(edgeId, edgeGrpcSession);
sessionsById.put(edgeGrpcSession.getSessionId(), edgeGrpcSession);
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.put(edgeId, true);
} finally {
newEventLock.unlock();
}
save(tenantId, edgeId, ACTIVITY_STATE, true);
long lastConnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs);
edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId());
pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT);
cancelScheduleEdgeEventsCheck(edgeId);
edgeEventsMigrationProcessed.putIfAbsent(edgeId, Boolean.FALSE);
scheduleEdgeEventsCheck(edgeGrpcSession);
}
private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) {
EdgeGrpcSession session = sessions.get(edgeId);
if (session != null) {
if (session.isSyncInProgress()) {
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId);
} else {
boolean success = false;
if (session.isConnected()) {
session.startSyncProcess(true);
success = true;
}
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId);
}
}
}
@Override
public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer<FromEdgeSyncResponse> responseConsumer) {
ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId());
UUID requestId = request.getId();
EdgeGrpcSession session = sessions.get(request.getEdgeId());
if (session != null && session.isSyncInProgress()) {
responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment"));
} else {
log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId());
localSyncEdgeRequests.put(requestId, responseConsumer);
clusterService.pushEdgeSyncRequestToEdge(request);
scheduleSyncRequestTimeout(request, requestId);
}
}
private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) {
log.trace("[{}] scheduling sync edge request", requestId);
executorService.schedule(() -> {
log.trace("[{}] checking if sync edge request is not processed...", requestId);
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
log.trace("[{}] timeout for processing sync edge request.", requestId);
consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected"));
}
}, 20, TimeUnit.SECONDS);
}
private void processSyncResponse(FromEdgeSyncResponse response) {
log.trace("[{}] Received response from sync service: [{}]", response.getId(), response);
UUID requestId = response.getId();
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
consumer.accept(response);
} else {
log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response);
}
}
private void scheduleEdgeEventsCheck(EdgeGrpcSession session) {
EdgeId edgeId = session.getEdge().getId();
TenantId tenantId = session.getEdge().getTenantId();
cancelScheduleEdgeEventsCheck(edgeId);
if (sessions.containsKey(edgeId)) {
ScheduledFuture<?> edgeEventCheckTask = edgeEventProcessingExecutorService.schedule(() -> {
try {
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
if (Boolean.TRUE.equals(sessionNewEvents.get(edgeId))) {
log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, false);
session.processHighPriorityEvents();
processEdgeEventMigrationIfNeeded(session, edgeId);
if (Boolean.TRUE.equals(edgeEventsMigrationProcessed.get(edgeId))) {
Futures.addCallback(session.processEdgeEvents(), new FutureCallback<>() {
@Override
public void onSuccess(Boolean newEventsAdded) {
if (Boolean.TRUE.equals(newEventsAdded)) {
log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId());
sessionNewEvents.put(edgeId, true);
}
scheduleEdgeEventsCheck(session);
}
@Override
public void onFailure(Throwable t) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), t);
scheduleEdgeEventsCheck(session);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
scheduleEdgeEventsCheck(session);
}
} else {
scheduleEdgeEventsCheck(session);
}
} finally {
newEventLock.unlock();
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), e);
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
sessionEdgeEventChecks.put(edgeId, edgeEventCheckTask);
log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId());
} else {
log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]",
tenantId, edgeId.getId());
}
}
private void processEdgeEventMigrationIfNeeded(EdgeGrpcSession session, EdgeId edgeId) throws Exception {
boolean isMigrationProcessed = edgeEventsMigrationProcessed.getOrDefault(edgeId, Boolean.FALSE);
if (!isMigrationProcessed) {
log.info("Starting edge event migration for edge [{}]", edgeId.getId());
Boolean eventsExist = session.migrateEdgeEvents().get();
if (Boolean.TRUE.equals(eventsExist)) {
log.info("Migration still in progress for edge [{}]", edgeId.getId());
sessionNewEvents.put(edgeId, true);
scheduleEdgeEventsCheck(session);
} else if (Boolean.FALSE.equals(eventsExist)) {
log.info("Migration completed for edge [{}]", edgeId.getId());
edgeEventsMigrationProcessed.put(edgeId, true);
}
}
}
private void cancelScheduleEdgeEventsCheck(EdgeId edgeId) {
log.trace("[{}] cancelling edge event check for edge", edgeId);
if (sessionEdgeEventChecks.containsKey(edgeId)) {
ScheduledFuture<?> sessionEdgeEventCheck = sessionEdgeEventChecks.get(edgeId);
if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) {
sessionEdgeEventCheck.cancel(true);
sessionEdgeEventChecks.remove(edgeId);
}
}
}
private void onEdgeDisconnect(Edge edge, UUID sessionId) {
EdgeId edgeId = edge.getId();
log.info("[{}][{}] edge disconnected!", edgeId, sessionId);
EdgeGrpcSession current = sessions.get(edgeId);
if (current != null && current.getSessionId().equals(sessionId)) {
EdgeGrpcSession toRemove = sessions.remove(edgeId);
final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock());
newEventLock.lock();
try {
sessionNewEvents.remove(edgeId);
} finally {
newEventLock.unlock();
}
destroySession(toRemove);
sessionsById.remove(sessionId);
TenantId tenantId = toRemove.getEdge().getTenantId();
save(tenantId, edgeId, ACTIVITY_STATE, false);
long lastDisconnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs);
pushRuleEngineMessage(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT);
cancelScheduleEdgeEventsCheck(edgeId);
} else {
log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId);
EdgeGrpcSession stale = sessionsById.remove(sessionId);
if (stale != null) {
try {
destroySession(stale);
log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId);
} catch (Exception e) {
log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId, e);
}
} else {
log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId);
}
}
edgeIdServiceIdCache.evict(edgeId);
}
private void destroySession(EdgeGrpcSession session) {
try (session) {
if (!session.destroy()) {
log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId());
zombieSessions.add(session);
}
} catch (Exception e) {
log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]",
session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId(), e);
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, long value) {
log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new LongDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new LongDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) {
log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
ctx.getTsSubService().saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new BooleanDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(key, value))
.callback(new AttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) {
try {
EdgeId edgeId = edge.getId();
ObjectNode edgeState = JacksonUtil.newObjectNode();
boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType);
if (isConnected) {
edgeState.put(ACTIVITY_STATE, true);
edgeState.put(LAST_CONNECT_TIME, ts);
} else {
edgeState.put(ACTIVITY_STATE, false);
edgeState.put(LAST_DISCONNECT_TIME, ts);
}
ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder()
.tenantId(tenantId)
.customerId(edge.getCustomerId())
.edgeId(edgeId)
.edgeName(edge.getName())
.connected(isConnected).build());
String data = JacksonUtil.toString(edgeState);
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg()
.type(msgType)
.originator(edgeId)
.copyMetaData(md)
.dataType(TbMsgDataType.JSON)
.data(data)
.build();
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e);
}
}
private void cleanupZombieSessions() {
try {
tryToDestroyZombieSessions(getZombieSessions(sessions.values()), s -> sessions.remove(s.getEdge().getId()));
tryToDestroyZombieSessions(getZombieSessions(sessionsById.values()), s -> sessionsById.remove(s.getSessionId()));
zombieSessions.removeIf(zombie -> {
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName());
return false;
}
});
} catch (Exception e) {
log.warn("Failed to cleanup kafka sessions", e);
}
}
private List<EdgeGrpcSession> getZombieSessions(Collection<EdgeGrpcSession> sessions) {
List<EdgeGrpcSession> result = new ArrayList<>();
for (EdgeGrpcSession session : sessions) {
if (isKafkaSessionAndZombie(session)) {
result.add(session);
}
}
return result;
}
private void tryToDestroyZombieSessions(List<EdgeGrpcSession> sessionsToRemove, Function<EdgeGrpcSession, EdgeGrpcSession> removeFunc) {
for (EdgeGrpcSession toRemove : sessionsToRemove) {
log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getEdge().getId());
if (toRemove.destroy()) {
removeFunc.apply(toRemove);
}
}
}
private boolean isKafkaSessionAndZombie(EdgeGrpcSession session) {
if (session instanceof KafkaEdgeGrpcSession kafkaSession) {
log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}",
kafkaSession.getEdge().getId(),
kafkaSession.isConnected(),
kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null);
return !kafkaSession.isConnected() &&
kafkaSession.getConsumer() != null &&
kafkaSession.getConsumer().getConsumer() != null &&
!kafkaSession.getConsumer().getConsumer().isStopped();
}
return false;
}
}

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

File diff suppressed because it is too large

1
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java

@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import java.util.function.Consumer;

57
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java

@ -16,19 +16,70 @@
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.SettableFuture;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.EdgeVersion;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Data
@Getter
public class EdgeSessionState {
private final Map<Integer, DownlinkMsg> pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>());
@Setter
private SettableFuture<Boolean> sendDownlinkMsgsFuture;
@Setter
private ScheduledFuture<?> scheduledSendDownlinkTask;
@Setter
private volatile boolean connected;
@Setter
private EdgeVersion edgeVersion;
private final UUID sessionId = UUID.randomUUID();
private final Map<Integer, DownlinkMsg> pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>());
private final Lock sequenceDependencyLock = new ReentrantLock();
private final AtomicBoolean syncInProgress = new AtomicBoolean(false);
private TenantId tenantId;
private Edge edge;
public void setEdge(Edge edge) {
if (edge == null) {
return;
}
this.edge = edge;
this.tenantId = edge.getTenantId();
}
public EdgeId getEdgeId() {
return edge != null ? edge.getId() : null;
}
public boolean tryStartSync() {
return syncInProgress.compareAndSet(false, true);
}
public void finishSync() {
syncInProgress.set(false);
}
public boolean isSyncInProgress() {
return syncInProgress.get();
}
public void lockSequenceDependency() {
sequenceDependencyLock.lock();
}
public void unlockSequenceDependency() {
sequenceDependencyLock.unlock();
}
}

238
application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java

@ -0,0 +1,238 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger;
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg;
import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg;
import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg;
import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg;
import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg;
import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityDataProto;
import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg;
import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg;
import org.thingsboard.server.gen.edge.v1.RelationRequestMsg;
import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg;
import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg;
import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg;
import org.thingsboard.server.gen.edge.v1.UserUpdateMsg;
import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.ArrayList;
import java.util.List;
@Service
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeUplinkMessageDispatcher {
private final EdgeContextComponent ctx;
public ListenableFuture<List<Void>> processUplinkMsg(EdgeSessionState state, UplinkMsg uplinkMsg) {
Edge edge = state.getEdge();
List<ListenableFuture<Void>> result = new ArrayList<>();
try {
if (uplinkMsg.getDeviceProfileUpdateMsgCount() > 0) {
for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : uplinkMsg.getDeviceProfileUpdateMsgList()) {
result.add(ctx.getDeviceProfileProcessor().processDeviceProfileMsgFromEdge(edge.getTenantId(), edge, deviceProfileUpdateMsg));
}
}
if (uplinkMsg.getDeviceUpdateMsgCount() > 0) {
for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceMsgFromEdge(edge.getTenantId(), edge, deviceUpdateMsg));
}
}
if (uplinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) {
for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg : uplinkMsg.getDeviceCredentialsUpdateMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceCredentialsMsgFromEdge(edge.getTenantId(), edge.getId(), deviceCredentialsUpdateMsg));
}
}
if (uplinkMsg.getAssetProfileUpdateMsgCount() > 0) {
for (AssetProfileUpdateMsg assetProfileUpdateMsg : uplinkMsg.getAssetProfileUpdateMsgList()) {
result.add(ctx.getAssetProfileProcessor().processAssetProfileMsgFromEdge(edge.getTenantId(), edge, assetProfileUpdateMsg));
}
}
if (uplinkMsg.getAssetUpdateMsgCount() > 0) {
for (AssetUpdateMsg assetUpdateMsg : uplinkMsg.getAssetUpdateMsgList()) {
result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg));
}
}
if (uplinkMsg.getRuleChainUpdateMsgCount() > 0) {
for (RuleChainUpdateMsg ruleChainUpdateMsg : uplinkMsg.getRuleChainUpdateMsgList()) {
result.add(ctx.getRuleChainProcessor().processRuleChainMsgFromEdge(edge.getTenantId(), edge, ruleChainUpdateMsg));
}
}
if (uplinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) {
for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg : uplinkMsg.getRuleChainMetadataUpdateMsgList()) {
result.add(ctx.getRuleChainProcessor().processRuleChainMetadataMsgFromEdge(edge.getTenantId(), edge, ruleChainMetadataUpdateMsg));
}
}
if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) {
for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) {
result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg));
}
}
if (uplinkMsg.getEntityDataCount() > 0) {
for (EntityDataProto entityData : uplinkMsg.getEntityDataList()) {
result.addAll(ctx.getTelemetryProcessor().processTelemetryMsg(edge.getTenantId(), entityData));
}
}
if (uplinkMsg.getAlarmUpdateMsgCount() > 0) {
for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) {
result.add(ctx.getAlarmProcessor().processAlarmMsgFromEdge(edge.getTenantId(), edge.getId(), alarmUpdateMsg));
}
}
if (uplinkMsg.getAlarmCommentUpdateMsgCount() > 0) {
for (AlarmCommentUpdateMsg alarmCommentUpdateMsg : uplinkMsg.getAlarmCommentUpdateMsgList()) {
result.add(ctx.getAlarmCommentProcessor().processAlarmCommentMsgFromEdge(edge.getTenantId(), edge.getId(), alarmCommentUpdateMsg));
}
}
if (uplinkMsg.getRelationUpdateMsgCount() > 0) {
for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) {
result.add(ctx.getRelationProcessor().processRelationMsgFromEdge(edge.getTenantId(), edge, relationUpdateMsg));
}
}
if (uplinkMsg.getDashboardUpdateMsgCount() > 0) {
for (DashboardUpdateMsg dashboardUpdateMsg : uplinkMsg.getDashboardUpdateMsgList()) {
result.add(ctx.getDashboardProcessor().processDashboardMsgFromEdge(edge.getTenantId(), edge, dashboardUpdateMsg));
}
}
if (uplinkMsg.getResourceUpdateMsgCount() > 0) {
for (ResourceUpdateMsg resourceUpdateMsg : uplinkMsg.getResourceUpdateMsgList()) {
result.add(ctx.getResourceProcessor().processResourceMsgFromEdge(edge.getTenantId(), edge, resourceUpdateMsg));
}
}
if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) {
for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg));
}
}
if (uplinkMsg.getAttributesRequestMsgCount() > 0) {
for (AttributesRequestMsg attributesRequestMsg : uplinkMsg.getAttributesRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processAttributesRequestMsg(edge.getTenantId(), edge, attributesRequestMsg));
}
}
if (uplinkMsg.getRelationRequestMsgCount() > 0) {
for (RelationRequestMsg relationRequestMsg : uplinkMsg.getRelationRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg));
}
}
if (uplinkMsg.getCalculatedFieldRequestMsgCount() > 0) {
for (CalculatedFieldRequestMsg calculatedFieldRequestMsg : uplinkMsg.getCalculatedFieldRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processCalculatedFieldRequestMsg(edge.getTenantId(), edge, calculatedFieldRequestMsg));
}
}
if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) {
for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg));
}
}
if (uplinkMsg.getDeviceCredentialsRequestMsgCount() > 0) {
for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg : uplinkMsg.getDeviceCredentialsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processDeviceCredentialsRequestMsg(edge.getTenantId(), edge, deviceCredentialsRequestMsg));
}
}
if (uplinkMsg.getDeviceRpcCallMsgCount() > 0) {
for (DeviceRpcCallMsg deviceRpcCallMsg : uplinkMsg.getDeviceRpcCallMsgList()) {
result.add(ctx.getDeviceProcessor().processDeviceRpcCallFromEdge(edge.getTenantId(), edge, deviceRpcCallMsg));
}
}
if (uplinkMsg.getWidgetBundleTypesRequestMsgCount() > 0) {
for (WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg : uplinkMsg.getWidgetBundleTypesRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processWidgetBundleTypesRequestMsg(edge.getTenantId(), edge, widgetBundleTypesRequestMsg));
}
}
if (uplinkMsg.getEntityViewsRequestMsgCount() > 0) {
for (EntityViewsRequestMsg entityViewRequestMsg : uplinkMsg.getEntityViewsRequestMsgList()) {
result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg));
}
}
if (uplinkMsg.getCalculatedFieldUpdateMsgCount() > 0) {
for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : uplinkMsg.getCalculatedFieldUpdateMsgList()) {
result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg));
}
}
if (uplinkMsg.getAiModelUpdateMsgCount() > 0) {
for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) {
result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg));
}
}
if (uplinkMsg.getUserUpdateMsgCount() > 0) {
for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) {
for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) {
for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) {
state.lockSequenceDependency();
try {
result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg));
} finally {
state.unlockSequenceDependency();
}
}
}
} catch (Exception e) {
String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg);
log.trace("[{}][{}] Can't process uplink msg [{}]", edge.getTenantId(), edge.getId(), uplinkMsg, e);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(edge.getTenantId()).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
return Futures.immediateFailedFuture(e);
}
return Futures.allAsList(result);
}
}

131
application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java

@ -0,0 +1,131 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import io.grpc.Server;
import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts;
import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext;
import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc.EdgeRpcServiceImplBase;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class GrpcServer {
@Value("${edges.rpc.port}")
private int rpcPort;
@Value("${edges.rpc.ssl.enabled}")
private boolean sslEnabled;
@Value("${edges.rpc.ssl.cert}")
private String certFileResource;
@Value("${edges.rpc.ssl.private_key}")
private String privateKeyResource;
@Value("${edges.rpc.ssl.key_password:}")
private String keyPassword;
@Value("${edges.rpc.client_max_keep_alive_time_sec:1}")
private int clientMaxKeepAliveTimeSec;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
private int maxInboundMessageSize;
@Value("${edges.rpc.keep_alive_time_sec:10}")
private int keepAliveTimeSec;
@Value("${edges.rpc.keep_alive_timeout_sec:5}")
private int keepAliveTimeoutSec;
private final EdgeRpcServiceImplBase edgeGrpcService;
private Server server;
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
public void onStartUp() {
log.info("Initializing Edge RPC server!");
NettyServerBuilder serverBuilder = NettyServerBuilder.forPort(rpcPort)
.permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS)
.keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS)
.permitKeepAliveWithoutCalls(true)
.maxInboundMessageSize(maxInboundMessageSize)
.addService(edgeGrpcService);
if (sslEnabled) {
setupSsl(serverBuilder);
}
server = serverBuilder.build();
log.info("Going to start Edge RPC server using port: {}", rpcPort);
try {
server.start();
} catch (IOException e) {
log.error("Failed to start Edge RPC server!", e);
throw new RuntimeException("Failed to start Edge RPC server!", e);
}
log.info("Edge RPC server initialized!");
}
/**
* Configures TLS for the Edge gRPC server.
* <p>
* Delegates PEM parsing and key management to {@link PemSslCredentials} — the same
* class used by MQTT, CoAP, and LwM2M transports — which supports:
* <ul>
* <li>Separate certificate and private key files (classic two-file setup)</li>
* <li>Combined PEM: certificate chain + private key in a single {@code cert} file
* ({@code private_key} left empty)</li>
* <li>Encrypted private keys (password supplied via {@code key_password})</li>
* </ul>
* Path resolution (for both {@code cert} and {@code private_key}) is handled by
* {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}:
* absolute path → relative / working-dir → classpath → {@code classpath:} prefix.
*/
void setupSsl(NettyServerBuilder builder) {
try {
PemSslCredentials credentials = new PemSslCredentials();
credentials.setCertFile(certFileResource);
credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource);
credentials.setKeyPassword(keyPassword);
credentials.init(false);
SslContext sslContext = GrpcSslContexts.configure(
SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build();
builder.sslContext(sslContext);
} catch (Exception e) {
log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e);
throw new RuntimeException("Unable to set up SSL context!", e);
}
}
@PreDestroy
public void destroy() {
if (server != null) {
server.shutdownNow();
}
}
}

186
application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java

@ -1,186 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
@Slf4j
public class KafkaEdgeGrpcSession extends EdgeGrpcSession {
private final TopicService topicService;
private final TbCoreQueueFactory tbCoreQueueFactory;
private final KafkaAdmin kafkaAdmin;
private volatile boolean isHighPriorityProcessing;
@Getter
private QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer;
private ExecutorService consumerExecutor;
public KafkaEdgeGrpcSession(EdgeContextComponent ctx, TopicService topicService, TbCoreQueueFactory tbCoreQueueFactory,
KafkaAdmin kafkaAdmin, StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSession> sessionOpenListener, BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService, int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
this.topicService = topicService;
this.tbCoreQueueFactory = tbCoreQueueFactory;
this.kafkaAdmin = kafkaAdmin;
}
private void processMsgs(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer) {
log.trace("[{}][{}] starting processing edge events", tenantId, edge.getId());
if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edge.getId(), isConnected(), isSyncInProgress(), isHighPriorityProcessing);
return;
}
List<EdgeEvent> edgeEvents = new ArrayList<>();
for (TbProtoQueueMsg<ToEdgeEventNotificationMsg> msg : msgs) {
EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
edgeEvents.add(edgeEvent);
}
List<DownlinkMsg> downlinkMsgsPack = convertToDownlinkMsgsPack(edgeEvents);
boolean isInterrupted = true;
try {
isInterrupted = sendDownlinkMsgsPack(downlinkMsgsPack).get();
if (isInterrupted) {
log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId());
}
} catch (Exception e) {
log.error("[{}][{}] Failed to process downlink messages", tenantId, edge.getId(), e);
}
if (!isInterrupted) {
consumer.commit();
}
}
@Override
public ListenableFuture<Boolean> migrateEdgeEvents() throws Exception {
return super.processEdgeEvents();
}
@Override
public ListenableFuture<Boolean> processEdgeEvents() {
if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edge != null ? edge.getId() : null, isConnected(), isSyncInProgress(), isHighPriorityProcessing);
return Futures.immediateFuture(Boolean.FALSE);
}
if (consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped())) {
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
try {
consumerExecutor.shutdown();
awaitConsumerTermination();
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edge.getId(), e);
}
}
this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer"));
this.consumer = QueueConsumerManager.<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>builder()
.name("TB Edge events [" + edge.getId() + "]")
.msgPackProcessor(this::processMsgs)
.pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval())
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId()))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edge.getId())
.build();
consumer.subscribe();
consumer.launch();
} catch (Exception e) {
destroy();
log.warn("[{}][{}] Failed to start edge event consumer", sessionId, edge.getId(), e);
}
}
return Futures.immediateFuture(Boolean.FALSE);
}
@Override
public void processHighPriorityEvents() {
isHighPriorityProcessing = true;
super.processHighPriorityEvents();
isHighPriorityProcessing = false;
}
@Override
public boolean destroy() {
try {
if (consumer != null) {
log.info("[{}][{}] Stopping edge event consumer...", tenantId, edge != null ? edge.getId() : null);
consumer.stop();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to stop edge event consumer", tenantId, edge.getId(), e);
return false;
}
consumer = null;
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
consumerExecutor.shutdown();
awaitConsumerTermination();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown edge event consumer executor", tenantId, edge.getId(), e);
return false;
}
return true;
}
private void awaitConsumerTermination() {
try {
consumerExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS);
} catch (InterruptedException ie) {
log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", tenantId, edge.getId());
}
}
@Override
public void cleanUp() {
String topic = topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edge.getId()).getTopic();
kafkaAdmin.deleteTopic(topic);
kafkaAdmin.deleteConsumerGroup(topic);
}
}

46
application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java

@ -1,46 +0,0 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
@Slf4j
public class PostgresEdgeGrpcSession extends EdgeGrpcSession {
PostgresEdgeGrpcSession(EdgeContextComponent ctx, StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSession> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService,
int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) {
super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
}
@Override
public ListenableFuture<Boolean> migrateEdgeEvents() {
return Futures.immediateFuture(Boolean.FALSE);
}
}

23
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java

@ -0,0 +1,23 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor;
import com.google.common.util.concurrent.ListenableFuture;
public interface EdgeEventsDispatcher {
ListenableFuture<Boolean> processNewEvents() throws Exception;
}

218
application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java

@ -0,0 +1,218 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.processor;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.data.util.Pair;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.TimePageLink;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
@Slf4j
@RequiredArgsConstructor
public class PostgresGeneralEdgeEventsDispatcher implements EdgeEventsDispatcher {
private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs";
private static final String QUEUE_START_SEQ_ID_ATTR_KEY = "queueStartSeqId";
private final EdgeSession session;
private final EdgeContextComponent ctx;
private Long newStartTs;
private Long previousStartTs;
private Long newStartSeqId;
private Long previousStartSeqId;
@Override
public ListenableFuture<Boolean> processNewEvents() throws Exception {
EdgeSessionState state = session.getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
UUID sessionId = state.getSessionId();
SettableFuture<Boolean> result = SettableFuture.create();
if (state.isConnected() && !state.isSyncInProgress()) {
setLocalPrevStartTsAndSeqId(tenantId, edgeId, sessionId);
GeneralEdgeEventFetcher fetcher = initGeneralEdgeEventFetcher();
log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}",
tenantId, edgeId, previousStartTs, previousStartSeqId);
Futures.addCallback(session.fetchAndSendEdgeEvents(fetcher), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Pair<Long, Long> newStartTsAndSeqId) {
if (newStartTsAndSeqId != null) {
Futures.addCallback(updateQueueStartTsAndSeqId(tenantId, edgeId, newStartTsAndSeqId), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable AttributesSaveResult saveResult) {
log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edgeId, newStartTsAndSeqId);
boolean newEventsAvailable;
if (fetcher.isSeqIdNewCycleStarted()) {
newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId);
} else {
newEventsAvailable = isSeqIdStartedNewCycle(tenantId, edgeId, sessionId);
if (!newEventsAvailable) {
newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId);
}
}
result.set(newEventsAvailable);
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edgeId, newStartTsAndSeqId, t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edgeId);
result.set(Boolean.FALSE);
}
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Failed to process events", tenantId, edgeId, t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
if (state.isSyncInProgress()) {
log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edgeId);
result.set(Boolean.TRUE);
} else {
log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edgeId);
result.set(null);
}
}
return result;
}
private boolean isSeqIdStartedNewCycle(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
try {
log.trace("[{}][{}][{}] Checking if seq id started new cycle", tenantId, edgeId, sessionId);
TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService()
.findEdgeEvents(tenantId, edgeId, 0L, previousStartSeqId == 0 ? null : previousStartSeqId - 1, pageLink);
boolean result = !edgeEvents.getData().isEmpty();
log.trace("[{}][{}][{}] Result of check if seq id started new cycle, result = {}", tenantId, edgeId, sessionId, result);
return result;
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute isSeqIdStartedNewCycle", tenantId, edgeId, sessionId, e);
}
return false;
}
private boolean isNewEdgeEventsAvailable(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
try {
log.trace("[{}][{}][{}] Checking if new edge events available", tenantId, edgeId, sessionId);
TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis());
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService().findEdgeEvents(tenantId, edgeId, newStartSeqId, null, pageLink);
boolean available = !edgeEvents.getData().isEmpty() || session.hasHighPriorityEvents();
log.trace("[{}][{}][{}] Result of check if new edge events available, result = {}", tenantId, edgeId, sessionId, available);
return available;
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute isNewEdgeEventsAvailable", tenantId, edgeId, sessionId, e);
}
return false;
}
private ListenableFuture<AttributesSaveResult> updateQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, Pair<Long, Long> pair) {
newStartTs = pair.getFirst();
newStartSeqId = pair.getSecond();
log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", session.getState().getSessionId(), edgeId, newStartTs, newStartSeqId);
List<AttributeKvEntry> attributes = List.of(
new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis()),
new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis())
);
return ctx.getAttributesService().save(tenantId, edgeId, AttributeScope.SERVER_SCOPE, attributes);
}
private GeneralEdgeEventFetcher initGeneralEdgeEventFetcher() {
return new GeneralEdgeEventFetcher(
previousStartTs,
previousStartSeqId,
false,
Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()),
ctx.getEdgeEventService(),
ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis());
}
private void setLocalPrevStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) throws InterruptedException, ExecutionException {
Pair<Long, Long> startTsAndSeqId = getQueueStartTsAndSeqId(tenantId, edgeId, sessionId).get();
previousStartTs = startTsAndSeqId.getFirst();
previousStartSeqId = startTsAndSeqId.getSecond();
}
private ListenableFuture<Pair<Long, Long>> getQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
ListenableFuture<List<AttributeKvEntry>> future = ctx.getAttributesService()
.find(tenantId, edgeId, AttributeScope.SERVER_SCOPE, Arrays.asList(QUEUE_START_TS_ATTR_KEY, QUEUE_START_SEQ_ID_ATTR_KEY));
return Futures.transform(future, attributeKvEntries -> {
long startTs = 0L;
long startSeqId = 0L;
for (AttributeKvEntry attributeKvEntry : attributeKvEntries) {
if (QUEUE_START_TS_ATTR_KEY.equals(attributeKvEntry.getKey())) {
startTs = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L;
}
if (QUEUE_START_SEQ_ID_ATTR_KEY.equals(attributeKvEntry.getKey())) {
startSeqId = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L;
}
}
if (startSeqId == 0L) {
startSeqId = findStartSeqIdFromOldestEventIfAny(tenantId, edgeId, sessionId);
}
return Pair.of(startTs, startSeqId);
}, ctx.getGrpcCallbackExecutorService());
}
private long findStartSeqIdFromOldestEventIfAny(TenantId tenantId, EdgeId edgeId, UUID sessionId) {
long startSeqId = 0L;
try {
TimePageLink pageLink = new TimePageLink(1, 0, null, null, null, null);
PageData<EdgeEvent> edgeEvents = ctx.getEdgeEventService()
.findEdgeEvents(tenantId, edgeId, null, null, pageLink);
if (!edgeEvents.getData().isEmpty()) {
startSeqId = edgeEvents.getData().get(0).getSeqId() - 1;
}
} catch (Exception e) {
log.error("[{}][{}][{}] Failed to execute findStartSeqIdFromOldestEventIfAny", tenantId, edgeId, sessionId, e);
}
return startSeqId;
}
}

392
application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java

@ -0,0 +1,392 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.service;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.grpc.stub.StreamObserver;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cache.TbTransactionalCache;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg;
import org.thingsboard.server.common.msg.edge.EdgeSessionMsg;
import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse;
import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.rpc.EdgeRpcService;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.EdgeAttributeSaveCallback;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME;
import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME;
@Service
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService {
@Value("${edges.send_scheduler_pool_size}")
private int sendSchedulerPoolSize;
@Value("${edges.state.persistToTelemetry:false}")
private boolean persistToTelemetry;
@Autowired
@Lazy
private EdgeContextComponent ctx;
private final EdgeSessionsHolder sessions;
private final ApplicationContext applicationContext;
private final TbClusterService clusterService;
private final TbServiceInfoProvider serviceInfoProvider;
private final TelemetrySubscriptionService tsSubService;
private final TbTransactionalCache<EdgeId, String> edgeIdServiceIdCache;
private final ConcurrentMap<UUID, Consumer<FromEdgeSyncResponse>> localSyncEdgeRequests = new ConcurrentHashMap<>();
private ScheduledExecutorService executorService;
private ScheduledExecutorService sendDownlinkExecutorService;
@PostConstruct
public void onStartUp() {
this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service");
this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler");
}
@PreDestroy
private void preDestroy() {
shutdownExecutorSafely(executorService);
shutdownExecutorSafely(sendDownlinkExecutorService);
sessions.forEach(EdgeGrpcSessionManager::onEdgeDisconnect);
}
@Override
public StreamObserver<RequestMsg> handleMsgs(StreamObserver<ResponseMsg> outputStream) {
EdgeGrpcSessionManager sessionManager = applicationContext.getBean(EdgeGrpcSessionManager.class);
return sessionManager.initInputStream(outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService);
}
@Override
public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) {
switch (msg.getMsgType()) {
case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> {
EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg;
log.trace("[{}] edgeEventMsg [{}]", tenantId, msg);
onEdgeHighPriorityEvent(edgeHighPriorityMsg);
}
case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> {
EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg;
log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg);
onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId());
}
case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> {
ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg;
log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg);
startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId());
}
case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> {
FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg;
log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg);
processSyncResponse(fromEdgeSyncResponse);
}
}
}
@Override
public void updateEdge(TenantId tenantId, Edge edge) {
if (edge == null) {
log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId);
return;
}
EdgeGrpcSessionManager session = sessions.getByEdgeId(edge.getId());
if (session != null && session.getState().isConnected()) {
log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
session.onConfigurationUpdate(edge);
} else {
log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId());
}
}
@Override
public void deleteEdge(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId);
session.destroyAndMarkAsZombieIfFailed();
session.onEdgeRemoval();
sessions.remove(session);
}
}
@Override
public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer<FromEdgeSyncResponse> responseConsumer) {
ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId());
UUID requestId = request.getId();
EdgeGrpcSessionManager session = sessions.getByEdgeId(request.getEdgeId());
if (session != null && session.getState().isSyncInProgress()) {
responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment"));
} else {
log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId());
localSyncEdgeRequests.put(requestId, responseConsumer);
clusterService.pushEdgeSyncRequestToEdge(request);
scheduleSyncRequestTimeout(request, requestId);
}
}
private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSessionManager edgeSession) {
EdgeSessionState state = edgeSession.getState();
Edge edge = state.getEdge();
TenantId tenantId = state.getTenantId();
log.info("[{}][{}] edge [{}] connected successfully.", tenantId, state.getSessionId(), edgeId);
if (sessions.hasByEdgeId(edgeId)) {
EdgeGrpcSessionManager existingSession = sessions.getByEdgeId(edgeId);
if (existingSession != null) {
UUID sessionId = existingSession.getState().getSessionId();
log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, state.getSessionId(), sessionId, edgeId);
existingSession.destroyAndMarkAsZombieIfFailed();
sessions.removeBySessionId(sessionId);
}
}
sessions.put(edgeSession);
save(tenantId, edgeId, ACTIVITY_STATE, true);
long lastConnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs);
edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId());
pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT);
edgeSession.onEdgeConnect();
}
private void onEdgeDisconnect(Edge edge, UUID sessionId) {
TenantId tenantId = edge.getTenantId();
EdgeId edgeId = edge.getId();
log.info("[{}][{}] edge disconnected!", edgeId, sessionId);
EdgeGrpcSessionManager current = sessions.getByEdgeId(edgeId);
if (current != null && current.getState().getSessionId().equals(sessionId)) {
EdgeGrpcSessionManager toRemove = sessions.removeByEdgeId(edgeId);
if (toRemove != null) {
toRemove.onEdgeDisconnect();
toRemove.destroyAndMarkAsZombieIfFailed();
}
sessions.removeBySessionId(sessionId);
save(tenantId, edgeId, ACTIVITY_STATE, false);
long lastDisconnectTs = System.currentTimeMillis();
save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs);
pushRuleEngineMessage(tenantId, edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT);
} else {
log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId);
EdgeGrpcSessionManager stale = sessions.removeBySessionId(sessionId);
if (stale != null) {
try {
stale.destroyAndMarkAsZombieIfFailed();
log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId);
} catch (Exception e) {
log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId, e);
}
} else {
log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId);
}
}
edgeIdServiceIdCache.evict(edgeId);
}
private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) {
log.trace("[{}] scheduling sync edge request", requestId);
executorService.schedule(() -> {
log.trace("[{}] checking if sync edge request is not processed...", requestId);
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
log.trace("[{}] timeout for processing sync edge request.", requestId);
consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected"));
}
}, 20, TimeUnit.SECONDS);
}
private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) {
TenantId tenantId = msg.getTenantId();
EdgeEvent edgeEvent = msg.getEdgeEvent();
EdgeId edgeId = edgeEvent.getEdgeId();
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId);
session.addEventToHighPriorityQueue(edgeEvent);
}
}
private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null && session.getState().isConnected()) {
log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId());
session.onEdgeEventUpdate();
}
}
private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) {
EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId);
if (session != null) {
EdgeSessionState sessionState = session.getState();
if (sessionState.isSyncInProgress()) {
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId);
} else {
boolean success = false;
if (sessionState.isConnected()) {
session.startSyncProcess(true);
success = true;
}
clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId);
}
}
}
private void processSyncResponse(FromEdgeSyncResponse response) {
log.trace("[{}] Received response from sync service: [{}]", response.getId(), response);
UUID requestId = response.getId();
Consumer<FromEdgeSyncResponse> consumer = localSyncEdgeRequests.remove(requestId);
if (consumer != null) {
consumer.accept(response);
} else {
log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response);
}
}
private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) {
try {
EdgeId edgeId = edge.getId();
ObjectNode edgeState = JacksonUtil.newObjectNode();
boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType);
if (isConnected) {
edgeState.put(ACTIVITY_STATE, true);
edgeState.put(LAST_CONNECT_TIME, ts);
} else {
edgeState.put(ACTIVITY_STATE, false);
edgeState.put(LAST_DISCONNECT_TIME, ts);
}
ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder()
.tenantId(tenantId)
.customerId(edge.getCustomerId())
.edgeId(edgeId)
.edgeName(edge.getName())
.connected(isConnected).build());
String data = JacksonUtil.toString(edgeState);
TbMsgMetaData md = new TbMsgMetaData();
if (!persistToTelemetry) {
md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE);
md.putValue("edgeName", edge.getName());
md.putValue("edgeType", edge.getType());
}
TbMsg tbMsg = TbMsg.newMsg()
.type(msgType)
.originator(edgeId)
.copyMetaData(md)
.dataType(TbMsgDataType.JSON)
.data(data)
.build();
clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null);
} catch (Exception e) {
log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e);
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, long value) {
log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
tsSubService.saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new LongDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new LongDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) {
log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value);
if (persistToTelemetry) {
tsSubService.saveTimeseries(TimeseriesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.entry(new BooleanDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
} else {
tsSubService.saveAttributes(AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(edgeId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(key, value))
.callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value))
.build());
}
}
private void shutdownExecutorSafely(ExecutorService e) {
if (e != null && !e.isShutdown()) {
e.shutdown();
}
}
}

131
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java

@ -0,0 +1,131 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import org.thingsboard.server.service.edge.rpc.session.manager.KafkaBasedEdgeGrpcSessionManager;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
@Service
@Slf4j
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class DefaultZombieSessionCleanupService implements ZombieSessionCleanupService {
@Autowired
private EdgeSessionsHolder edgeSessionsHolder;
private final Queue<EdgeGrpcSessionManager> zombieSessions;
private final ScheduledExecutorService zombieSessionsExecutorService;
public DefaultZombieSessionCleanupService() {
this.zombieSessions = new ConcurrentLinkedQueue<>();
this.zombieSessionsExecutorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("zombie-sessions");
}
@PostConstruct
public void init() {
this.zombieSessionsExecutorService.scheduleAtFixedRate(this::cleanupZombieSessions, 30, 60, TimeUnit.SECONDS);
}
@PreDestroy
public void destroy() {
if (zombieSessionsExecutorService != null && !zombieSessionsExecutorService.isShutdown()) {
zombieSessionsExecutorService.shutdown();
}
}
@Override
public void add(EdgeGrpcSessionManager session) {
zombieSessions.add(session);
}
private void cleanupZombieSessions() {
try {
tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessions().values()),
s -> edgeSessionsHolder.removeByEdgeId(s.getState().getEdge().getId()));
tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessionsById().values()),
s -> edgeSessionsHolder.removeBySessionId(s.getState().getSessionId()));
zombieSessions.removeIf(zombie -> {
EdgeSessionState state = zombie.getState();
if (zombie.destroy()) {
log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].",
state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName());
return true;
} else {
log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].",
state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName());
return false;
}
});
} catch (Exception e) {
log.warn("Failed to cleanup zombie sessions", e);
}
}
private List<EdgeGrpcSessionManager> getZombieSessions(Collection<EdgeGrpcSessionManager> sessions) {
List<EdgeGrpcSessionManager> result = new ArrayList<>();
for (EdgeGrpcSessionManager session : sessions) {
if (isKafkaSessionAndZombie(session)) {
result.add(session);
}
}
return result;
}
private boolean isKafkaSessionAndZombie(EdgeGrpcSessionManager session) {
if (session instanceof KafkaBasedEdgeGrpcSessionManager kafkaSession) {
EdgeSessionState sessionState = kafkaSession.getState();
log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}",
sessionState.getEdgeId(),
sessionState.isConnected(),
kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null);
return !sessionState.isConnected() &&
kafkaSession.getConsumer() != null &&
kafkaSession.getConsumer().getConsumer() != null &&
!kafkaSession.getConsumer().getConsumer().isStopped();
}
return false;
}
private void tryToDestroyZombieSessions(List<EdgeGrpcSessionManager> sessionsToRemove, Function<EdgeGrpcSessionManager, EdgeGrpcSessionManager> removeFunc) {
for (EdgeGrpcSessionManager toRemove : sessionsToRemove) {
log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getState().getEdge().getId());
if (toRemove.destroy()) {
removeFunc.apply(toRemove);
}
}
}
}

16
application/src/main/java/org/thingsboard/server/service/edge/rpc/AttributeSaveCallback.java → application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java

@ -13,24 +13,29 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc;
package org.thingsboard.server.service.edge.rpc.session;
import com.google.common.util.concurrent.FutureCallback;
import jakarta.annotation.Nullable;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
@Slf4j
@AllArgsConstructor
public class AttributeSaveCallback implements FutureCallback<Void> {
public class EdgeAttributeSaveCallback implements FutureCallback<Void> {
private final TenantId tenantId;
private final EdgeId edgeId;
private final String key;
private final Object value;
public EdgeAttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) {
this.tenantId = tenantId;
this.edgeId = edgeId;
this.key = key;
this.value = value;
}
@Override
public void onSuccess(@Nullable Void result) {
log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value);
@ -40,4 +45,5 @@ public class AttributeSaveCallback implements FutureCallback<Void> {
public void onFailure(Throwable t) {
log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t);
}
}
}

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

@ -0,0 +1,621 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import com.datastax.oss.driver.api.core.uuid.Uuids;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;
import io.grpc.stub.StreamObserver;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.springframework.data.util.Pair;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EdgeUtils;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BooleanDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg;
import org.thingsboard.server.dao.edge.stats.EdgeStatsKey;
import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg;
import org.thingsboard.server.gen.edge.v1.ConnectResponseCode;
import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg;
import org.thingsboard.server.gen.edge.v1.EdgeConfiguration;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsgType;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg;
import org.thingsboard.server.gen.edge.v1.UplinkMsg;
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.EdgeSyncCursor;
import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher;
import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@Slf4j
@RequiredArgsConstructor
public class EdgeGrpcSession implements EdgeSession {
private static final int MAX_DOWNLINK_ATTEMPTS = 3;
private static final String RATE_LIMIT_REACHED = "Rate limit reached";
private final EdgeGrpcSessionManager parentManagerRef;
private final EdgeContextComponent ctx;
private final StreamObserver<ResponseMsg> outputStream;
private final DownlinkMessageMapper downlinkMessageMapper;
private final EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
private final BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener;
private final BiConsumer<Edge, UUID> sessionCloseListener;
private final ScheduledExecutorService sendDownlinkExecutorService;
private final int maxInboundMessageSize;
private final int maxHighPriorityQueueSizePerSession;
private final EdgeSessionState state = new EdgeSessionState();
private final Lock downlinkMsgLock = new ReentrantLock();
private final ConcurrentLinkedQueue<EdgeEvent> highPriorityQueue = new ConcurrentLinkedQueue<>();
private int clientMaxInboundMessageSize;
@Override
public EdgeSessionState getState() {
return state;
}
@Override
public StreamObserver<RequestMsg> initInputStream() {
return new StreamObserver<>() {
@Override
public void onNext(RequestMsg requestMsg) {
if (!state.isConnected() && requestMsg.getMsgType().equals(RequestMsgType.CONNECT_RPC_MESSAGE)) {
ConnectResponseMsg responseMsg = processConnect(requestMsg.getConnectRequestMsg());
outputStream.onNext(ResponseMsg.newBuilder()
.setConnectResponseMsg(responseMsg)
.build());
if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) {
outputStream.onError(new RuntimeException(responseMsg.getErrorMsg()));
} else {
if (requestMsg.getConnectRequestMsg().hasMaxInboundMessageSize()) {
log.debug("[{}][{}] Client max inbound message size: {}",
getTenantId(), getSessionId(), requestMsg.getConnectRequestMsg().getMaxInboundMessageSize());
clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize();
}
state.setConnected(true);
}
}
if (state.isConnected()) {
if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) {
if (requestMsg.hasSyncRequestMsg()) {
boolean fullSync = false;
if (requestMsg.getSyncRequestMsg().hasFullSync()) {
fullSync = requestMsg.getSyncRequestMsg().getFullSync();
}
startSyncProcess(fullSync);
} else {
state.finishSync();
saveSyncInProgressAsAttribute(false);
}
}
if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) {
if (requestMsg.hasUplinkMsg()) {
onUplinkMsg(requestMsg.getUplinkMsg());
}
if (requestMsg.hasDownlinkResponseMsg()) {
onDownlinkResponse(requestMsg.getDownlinkResponseMsg());
}
}
}
}
@Override
public void onError(Throwable t) {
log.trace("[{}][{}] Stream was terminated due to error:", getTenantId(), getSessionId(), t);
closeSession();
}
@Override
public void onCompleted() {
log.info("[{}][{}] Stream was closed and completed successfully!", getTenantId(), getSessionId());
closeSession();
}
private void closeSession() {
state.setConnected(false);
if (state.getEdge() != null) {
try {
sessionCloseListener.accept(state.getEdge(), getSessionId());
} catch (Exception ignored) {
}
}
try {
outputStream.onCompleted();
} catch (Exception ignored) {
}
}
};
}
@Override
public void startSyncProcess(boolean fullSync) {
if (state.tryStartSync()) {
saveSyncInProgressAsAttribute(true);
log.info("[{}][{}][{}] Staring edge sync process", getTenantId(), getEdgeId(), getSessionId());
interruptGeneralProcessingOnSync();
doSync(new EdgeSyncCursor(ctx, state.getEdge(), fullSync));
} else {
log.info("[{}][{}][{}] Sync is already started, skipping starting it now", getTenantId(), getEdgeId(), getSessionId());
}
}
@Override
public void sendDownlinkMsg(ResponseMsg responseMsg) {
if (state.isConnected()) {
String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000);
log.trace("[{}][{}] Sending downlink msg [{}]", getTenantId(), getEdgeId(), responseMsgStr);
downlinkMsgLock.lock();
String downlinkMsgStr = responseMsg.hasDownlinkMsg() ? String.valueOf(responseMsg.getDownlinkMsg().getDownlinkMsgId()) : responseMsgStr;
try {
outputStream.onNext(responseMsg);
} catch (Exception e) {
log.trace("[{}][{}] Failed to send downlink message [{}]", getTenantId(), getEdgeId(), downlinkMsgStr, e);
state.setConnected(false);
sessionCloseListener.accept(state.getEdge(), getSessionId());
} finally {
downlinkMsgLock.unlock();
}
log.trace("[{}][{}] downlink msg successfully sent [{}]", getTenantId(), getEdgeId(), downlinkMsgStr);
}
}
@Override
public void addHighPriorityEvent(EdgeEvent edgeEvent) {
while (highPriorityQueue.size() > maxHighPriorityQueueSizePerSession) {
EdgeEvent oldestHighPriority = highPriorityQueue.poll();
if (oldestHighPriority != null) {
log.warn("[{}][{}][{}] High priority queue is full. Removing oldest high priority event from queue {}",
state.getTenantId(), state.getEdgeId(), getSessionId(), oldestHighPriority);
}
}
highPriorityQueue.add(edgeEvent);
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_ADDED, state.getEdge().getTenantId(), edgeEvent.getEdgeId(), 1));
}
@Override
public void processHighPriorityEvents() {
try {
if (state.isConnected() && !state.isSyncInProgress()) {
if (highPriorityQueue.isEmpty()) {
return;
}
List<EdgeEvent> highPriorityEvents = new ArrayList<>();
EdgeEvent event;
while ((event = highPriorityQueue.poll()) != null) {
highPriorityEvents.add(event);
}
log.trace("[{}][{}] Sending high priority events {}", getTenantId(), getEdgeId(), highPriorityEvents.size());
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, highPriorityEvents);
sendDownlinkMsgsPack(downlinkMsgsPack).get();
}
} catch (Exception e) {
log.error("[{}] Failed to process high priority events", getEdgeId(), e);
}
}
@Override
public boolean hasHighPriorityEvents() {
return !highPriorityQueue.isEmpty();
}
@Override
public ListenableFuture<Pair<Long, Long>> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher) {
SettableFuture<Pair<Long, Long>> result = SettableFuture.create();
PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount());
fetchAndSendEdgeEvents(fetcher, pageLink, result);
return result;
}
@Override
public ListenableFuture<Boolean> sendDownlinkMsgsPack(List<DownlinkMsg> downlinkMsgsPack) {
interruptPreviousSendDownlinkMsgsTask();
state.setSendDownlinkMsgsFuture(SettableFuture.create());
state.getPendingMsgsMap().clear();
downlinkMsgsPack.forEach(msg -> state.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg));
scheduleDownlinkMsgsPackSend(1);
return state.getSendDownlinkMsgsFuture();
}
@Override
public void close() {
log.debug("[{}][{}] Closing session", getTenantId(), getSessionId());
state.setConnected(false);
try {
outputStream.onCompleted();
} catch (Exception e) {
log.debug("[{}][{}] Failed to close output stream: {}", getTenantId(), getSessionId(), e.getMessage());
}
}
private void onUplinkMsg(UplinkMsg uplinkMsg) {
if (isRateLimitViolated(uplinkMsg)) {
return;
}
Futures.addCallback(uplinkMessageDispatcher.processUplinkMsg(state, uplinkMsg), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable List<Void> result) {
sendResponseMessage(uplinkMsg.getUplinkMsgId(), true, null);
}
@Override
public void onFailure(Throwable t) {
String errorMsg = EdgeUtils.createErrorMsgFromRootCauseAndStackTrace(t);
sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
}
}, ctx.getGrpcCallbackExecutorService());
}
private boolean isRateLimitViolated(UplinkMsg uplinkMsg) {
if (!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES, getTenantId()) ||
!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, getTenantId(), getEdgeId())) {
String errorMsg = String.format("Failed to process uplink message. %s", RATE_LIMIT_REACHED);
sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg);
return true;
}
return false;
}
private void sendResponseMessage(int uplinkMsgId, boolean success, String errorMsg) {
UplinkResponseMsg.Builder responseBuilder = UplinkResponseMsg.newBuilder()
.setUplinkMsgId(uplinkMsgId)
.setSuccess(success);
if (errorMsg != null) {
responseBuilder.setErrorMsg(errorMsg);
}
sendDownlinkMsg(ResponseMsg.newBuilder()
.setUplinkResponseMsg(responseBuilder.build())
.build());
}
private void onDownlinkResponse(DownlinkResponseMsg msg) {
try {
if (msg.getSuccess()) {
state.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
ctx.getStatsCounterService().ifPresent(statsCounterService -> statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PUSHED, getTenantId(), getEdgeId(), 1));
log.debug("[{}][{}][{}] Msg has been processed successfully! Msg Id: [{}], Msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg);
} else {
log.debug("[{}][{}][{}] Msg processing failed! Msg Id: [{}], Error msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg.getErrorMsg());
DownlinkMsg downlinkMsg = state.getPendingMsgsMap().get(msg.getDownlinkMsgId());
// if NOT timeseries or attributes failures - ack failed downlink
if (downlinkMsg != null && downlinkMsg.getEntityDataCount() == 0) {
state.getPendingMsgsMap().remove(msg.getDownlinkMsgId());
}
}
if (state.getPendingMsgsMap().isEmpty()) {
log.debug("[{}][{}][{}] Pending msgs map is empty. Stopping current iteration", getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(false);
}
} catch (Exception e) {
log.error("[{}][{}] Can't process downlink response message [{}]", getTenantId(), getEdgeId(), msg, e);
}
}
private void interruptGeneralProcessingOnSync() {
log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(true);
}
private void doSync(EdgeSyncCursor cursor) {
if (cursor.hasNext()) {
EdgeEventFetcher next = cursor.getNext();
log.debug("[{}][{}] starting sync process, cursor current idx = {}, class = {}",
getTenantId(), getEdgeId(), cursor.getCurrentIdx(), next.getClass().getSimpleName());
ListenableFuture<Pair<Long, Long>> future = fetchAndSendEdgeEvents(next);
Futures.addCallback(future, new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Pair<Long, Long> result) {
doSync(cursor);
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Exception during sync process", getTenantId(), getEdgeId(), t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.info("[{}][{}] sync process completed", getTenantId(), getEdgeId());
DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder()
.setDownlinkMsgId(EdgeUtils.nextPositiveInt())
.setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build())
.build();
Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback<>() {
@Override
public void onSuccess(Boolean isInterrupted) {
markSyncCompletedSendEdgeEventUpdate();
}
@Override
public void onFailure(Throwable t) {
log.error("[{}][{}] Exception during sending sync complete", getTenantId(), getEdgeId(), t);
markSyncCompletedSendEdgeEventUpdate();
}
}, ctx.getGrpcCallbackExecutorService());
}
}
private void markSyncCompletedSendEdgeEventUpdate() {
state.finishSync();
saveSyncInProgressAsAttribute(false);
ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(getTenantId(), getEdgeId()));
}
private void fetchAndSendEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture<Pair<Long, Long>> result) {
Edge edge = state.getEdge();
TenantId tenantId = getTenantId();
try {
log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
processHighPriorityEvents();
PageData<EdgeEvent> pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink);
if (state.isConnected() && !pageData.getData().isEmpty()) {
if (fetcher instanceof GeneralEdgeEventFetcher) {
long queueSize = pageData.getTotalElements() - ((long) pageLink.getPageSize() * pageLink.getPage());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_LAG, tenantId, edge.getId(), queueSize));
}
log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, edge.getId(), pageData.getData().size());
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, pageData.getData());
Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() {
@Override
public void onSuccess(@Nullable Boolean isInterrupted) {
if (Boolean.TRUE.equals(isInterrupted)) {
log.debug("[{}][{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId(), getSessionId());
result.set(null);
} else {
if (state.isConnected() && pageData.hasNext()) {
fetchAndSendEdgeEvents(fetcher, pageLink.nextPageLink(), result);
} else {
EdgeEvent latestEdgeEvent = pageData.getData().get(pageData.getData().size() - 1);
UUID idOffset = latestEdgeEvent.getUuidId();
if (idOffset != null) {
Long newStartTs = Uuids.unixTimestamp(idOffset);
long newStartSeqId = latestEdgeEvent.getSeqId();
result.set(Pair.of(newStartTs, newStartSeqId));
} else {
result.set(null);
}
}
}
}
@Override
public void onFailure(Throwable t) {
log.error("[{}] Failed to send downlink msgs pack", edge.getId(), t);
result.setException(t);
}
}, ctx.getGrpcCallbackExecutorService());
} else {
log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink);
result.set(null);
}
} catch (Exception e) {
log.error("[{}] Failed to fetch edge events", edge.getId(), e);
result.setException(e);
}
}
private void scheduleDownlinkMsgsPackSend(int attempt) {
Runnable sendDownlinkMsgsTask = () -> {
try {
if (!state.isConnected()) {
stopCurrentSendDownlinkMsgsTask(true);
return;
}
if (!state.getPendingMsgsMap().values().isEmpty()) {
Edge edge = state.getEdge();
List<DownlinkMsg> copy = new ArrayList<>(state.getPendingMsgsMap().values());
if (attempt > 1) {
String error = "Failed to deliver the batch";
String failureMsg = String.format("{%s} (size: {%s})", error, copy.size());
if (attempt == 2) {
// Send a failure notification only on the second attempt.
// This ensures that failure alerts are sent just once to avoid redundant notifications.
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId())
.edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
}
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED, edge.getTenantId(), getEdgeId(), 1));
log.warn("[{}][{}] {} on attempt {}", getTenantId(), getEdgeId(), failureMsg, attempt);
log.debug("[{}][{}] entities in failed batch: {}", getTenantId(), getEdgeId(), copy);
}
log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", getTenantId(), getEdgeId(), copy.size());
for (DownlinkMsg downlinkMsg : copy) {
if (clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > clientMaxInboundMessageSize) {
String error = String.format("Client max inbound message size %s is exceeded. Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE " +
"env variable on the edge and restart it.", clientMaxInboundMessageSize);
String message = String.format("Downlink msg size %s exceeds client max inbound message size %s. " +
"Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE env variable on the edge and restart it.", downlinkMsg.getSerializedSize(), clientMaxInboundMessageSize);
log.error("[{}][{}][{}] {} Message {}", getTenantId(), getEdgeId(), getSessionId(), message, downlinkMsg);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId())
.edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(message).error(error).build());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), 1));
state.getPendingMsgsMap().remove(downlinkMsg.getDownlinkMsgId());
} else {
sendDownlinkMsg(ResponseMsg.newBuilder()
.setDownlinkMsg(downlinkMsg)
.build());
}
}
if (attempt < MAX_DOWNLINK_ATTEMPTS) {
scheduleDownlinkMsgsPackSend(attempt + 1);
} else {
String failureMsg = String.format("Failed to deliver messages: %s", copy);
log.warn("[{}][{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}",
getTenantId(), getEdgeId(), MAX_DOWNLINK_ATTEMPTS, copy);
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId()).edgeId(getEdgeId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg)
.error("Failed to deliver messages after " + MAX_DOWNLINK_ATTEMPTS + " attempts").build());
ctx.getStatsCounterService().ifPresent(statsCounterService ->
statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), copy.size()));
stopCurrentSendDownlinkMsgsTask(false);
}
} else {
stopCurrentSendDownlinkMsgsTask(false);
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", getTenantId(), getEdgeId(), e.getMessage(), e);
stopCurrentSendDownlinkMsgsTask(true);
}
};
if (attempt == 1) {
sendDownlinkExecutorService.submit(sendDownlinkMsgsTask);
} else {
state.setScheduledSendDownlinkTask(
sendDownlinkExecutorService.schedule(
sendDownlinkMsgsTask,
ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(),
TimeUnit.MILLISECONDS)
);
}
}
private void interruptPreviousSendDownlinkMsgsTask() {
if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()
|| state.getScheduledSendDownlinkTask() != null && !state.getScheduledSendDownlinkTask().isCancelled()) {
log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!",
getTenantId(), getEdgeId(), getSessionId());
stopCurrentSendDownlinkMsgsTask(true);
} else {
log.trace("[{}][{}][{}] Previous send downlink future is not active",
getTenantId(), getEdgeId(), getSessionId());
}
}
private void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) {
if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()) {
state.getSendDownlinkMsgsFuture().set(isInterrupted);
}
if (state.getScheduledSendDownlinkTask() != null) {
state.getScheduledSendDownlinkTask().cancel(true);
}
}
private ConnectResponseMsg processConnect(ConnectRequestMsg request) {
log.trace("[{}] processConnect [{}]", getSessionId(), request);
Optional<Edge> optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey());
if (optional.isPresent()) {
Edge edge = optional.get();
TenantId tenantId = edge.getTenantId();
state.setEdge(edge);
try {
if (edge.getSecret().equals(request.getEdgeSecret())) {
sessionOpenListener.accept(edge.getId(), parentManagerRef);
state.setEdgeVersion(request.getEdgeVersion());
processSaveEdgeVersionAsAttribute(request.getEdgeVersion().name());
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.ACCEPTED)
.setErrorMsg("")
.setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge))
.setMaxInboundMessageSize(maxInboundMessageSize)
.build();
}
String error = "Failed to validate the edge!";
String failureMsg = String.format("%s Provided request secret: %s", error, request.getEdgeSecret());
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build());
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.BAD_CREDENTIALS)
.setErrorMsg(failureMsg)
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
} catch (Exception e) {
String failureMsg = "Failed to process edge connection!";
ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId())
.customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build());
log.error(failureMsg, e);
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.SERVER_UNAVAILABLE)
.setErrorMsg(failureMsg)
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
}
}
return ConnectResponseMsg.newBuilder()
.setResponseCode(ConnectResponseCode.BAD_CREDENTIALS)
.setErrorMsg("Failed to find the edge! Routing key: " + request.getEdgeRoutingKey())
.setConfiguration(EdgeConfiguration.getDefaultInstance()).build();
}
private void processSaveEdgeVersionAsAttribute(String edgeVersion) {
AttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(new StringDataEntry(DataConstants.EDGE_VERSION_ATTR_KEY, edgeVersion), System.currentTimeMillis());
ctx.getAttributesService().save(getTenantId(), getEdgeId(), AttributeScope.SERVER_SCOPE, attributeKvEntry);
}
private void saveSyncInProgressAsAttribute(Boolean value) {
ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder()
.tenantId(getTenantId())
.entityId(getEdgeId())
.scope(AttributeScope.SERVER_SCOPE)
.entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
.callback(new EdgeAttributeSaveCallback(getTenantId(), getEdgeId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value))
.build());
}
private TenantId getTenantId() {
return state.getTenantId();
}
private EdgeId getEdgeId() {
return state.getEdgeId();
}
private UUID getSessionId() {
return state.getSessionId();
}
}

34
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java

@ -0,0 +1,34 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
public abstract class EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager {
protected abstract EdgeSession getSession();
@Override
public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) {
getSession().addHighPriorityEvent(edgeEvent);
}
@Override
public void startSyncProcess(boolean fullSync) {
getSession().startSyncProcess(fullSync);
}
}

43
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java

@ -0,0 +1,43 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import com.google.common.util.concurrent.ListenableFuture;
import io.grpc.stub.StreamObserver;
import org.springframework.data.util.Pair;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher;
import java.io.Closeable;
import java.util.List;
public interface EdgeSession extends Closeable {
StreamObserver<RequestMsg> initInputStream();
EdgeSessionState getState();
void startSyncProcess(boolean fullSync);
void sendDownlinkMsg(ResponseMsg responseMsg);
void addHighPriorityEvent(EdgeEvent edgeEvent);
void processHighPriorityEvents();
boolean hasHighPriorityEvents();
ListenableFuture<Pair<Long, Long>> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher);
ListenableFuture<Boolean> sendDownlinkMsgsPack(List<DownlinkMsg> downlinkMsgsPack);
}

83
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java

@ -0,0 +1,83 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.Consumer;
@Data
@Slf4j
@Component
@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true")
@TbCoreComponent
public class EdgeSessionsHolder {
private final ConcurrentMap<EdgeId, EdgeGrpcSessionManager> sessions = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, EdgeGrpcSessionManager> sessionsById = new ConcurrentHashMap<>();
public void forEach(Consumer<EdgeGrpcSessionManager> consumer) {
Set<EdgeGrpcSessionManager> unique = new HashSet<>(sessions.values());
unique.addAll(sessionsById.values());
unique.forEach(consumer);
}
public void put(EdgeGrpcSessionManager session) {
UUID sessionId = session.getState().getSessionId();
EdgeId edgeId = session.getState().getEdgeId();
sessionsById.put(sessionId, session);
sessions.put(edgeId, session);
}
public EdgeGrpcSessionManager getByEdgeId(EdgeId id) {
return sessions.get(id);
}
public boolean hasByEdgeId(EdgeId id) {
return sessions.containsKey(id);
}
public EdgeGrpcSessionManager removeByEdgeId(EdgeId id) {
return sessions.remove(id);
}
public EdgeGrpcSessionManager removeBySessionId(UUID sessionId) {
return sessionsById.remove(sessionId);
}
public void remove(EdgeGrpcSessionManager session) {
if (session == null) {
log.warn("Can't remove session from holder because it's null");
return;
}
EdgeSessionState sessionState = session.getState();
removeByEdgeId(sessionState.getEdgeId());
removeBySessionId(sessionState.getSessionId());
}
}

23
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java

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

149
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java

@ -0,0 +1,149 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Lazy;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.EdgeContextComponent;
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils;
import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSession;
import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSessionDelegate;
import org.thingsboard.server.service.edge.rpc.session.EdgeSession;
import org.thingsboard.server.service.edge.rpc.session.ZombieSessionCleanupService;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.BiConsumer;
@Slf4j
public abstract class AbstractEdgeGrpcSessionManager extends EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager {
@Value("${edges.max_high_priority_queue_size_per_session:10000}")
protected int maxHighPriorityQueueSizePerSession;
@Value("${edges.rpc.max_inbound_message_size:4194304}")
protected int maxInboundMessageSize;
@Autowired
protected ZombieSessionCleanupService zombieSessionCleanupService;
@Autowired
@Lazy
protected EdgeContextComponent ctx;
@Autowired
protected DownlinkMessageMapper downlinkMessageMapper;
@Autowired
protected EdgeUplinkMessageDispatcher uplinkMessageDispatcher;
@Getter
protected EdgeSession session;
private final Lock initLock = new ReentrantLock();
@Override
public StreamObserver<RequestMsg> initInputStream(StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService) {
if (session != null) {
throw new IllegalStateException("Session already initialized");
}
initLock.lock();
try {
this.session = new EdgeGrpcSession(
this, ctx, outputStream, downlinkMessageMapper, uplinkMessageDispatcher,
sessionOpenListener, sessionCloseListener,
sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession);
return session.initInputStream();
} finally {
initLock.unlock();
}
}
@Override
public EdgeSessionState getState() {
if (session == null) {
return null;
}
return session.getState();
}
@Override
public void onConfigurationUpdate(Edge edge) {
EdgeSessionState state = getState();
if (state == null) {
log.warn("Session state is null, skipping configuration update for edge [{}]", edge.getId());
return;
}
CustomerId stateCustomerId = state.getEdge().getCustomerId();
state.setEdge(edge);
if (stateCustomerId != null && !stateCustomerId.equals(edge.getCustomerId())) {
// do not send edge configuration message on customer update
// message send by separate flow from assign_to or unassing_from customer
return;
}
EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder()
.setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)).build();
ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder()
.setEdgeUpdateMsg(edgeConfig)
.build();
session.sendDownlinkMsg(edgeConfigMsg);
}
@Override
public void destroyAndMarkAsZombieIfFailed() {
EdgeSessionState state = getState();
final var finalSession = session;
try (finalSession) {
if (!destroy()) {
if (state != null) {
log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.",
state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId());
} else {
log.warn("Session destroy failed (state is null). Adding to zombie queue for later cleanup.");
}
zombieSessionCleanupService.add(this);
}
} catch (Exception e) {
if (state != null) {
log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]",
state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId(), e);
} else {
log.warn("Exception during session destroy (state is null)", e);
}
}
}
}

47
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import io.grpc.stub.StreamObserver;
import org.thingsboard.server.common.data.edge.Edge;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.gen.edge.v1.RequestMsg;
import org.thingsboard.server.gen.edge.v1.ResponseMsg;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import java.util.UUID;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.BiConsumer;
public interface EdgeGrpcSessionManager {
StreamObserver<RequestMsg> initInputStream(StreamObserver<ResponseMsg> outputStream,
BiConsumer<EdgeId, EdgeGrpcSessionManager> sessionOpenListener,
BiConsumer<Edge, UUID> sessionCloseListener,
ScheduledExecutorService sendDownlinkExecutorService);
EdgeSessionState getState();
void addEventToHighPriorityQueue(EdgeEvent edgeEvent);
void startSyncProcess(boolean fullSync);
void onEdgeConnect();
void onEdgeEventUpdate();
void onConfigurationUpdate(Edge edge);
void onEdgeDisconnect();
void onEdgeRemoval();
void destroyAndMarkAsZombieIfFailed();
boolean destroy();
}

302
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java

@ -0,0 +1,302 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.util.ProtoUtils;
import org.thingsboard.server.gen.edge.v1.DownlinkMsg;
import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.TopicService;
import org.thingsboard.server.queue.kafka.KafkaAdmin;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.TbKafkaComponent;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@Slf4j
@TbKafkaComponent
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager {
private static final int NO_INITIAL_DELAY_VALUE = 0;
private final TbCoreQueueFactory tbCoreQueueFactory;
private final TopicService topicService;
private final KafkaAdmin kafkaAdmin;
private final EdgeSessionsHolder sessions;
private final AtomicReference<ScheduledFuture<?>> initMigrationAndProcessingFutureRef = new AtomicReference<>();
private final AtomicReference<ScheduledFuture<?>> highPriorityProcessingFutureRef = new AtomicReference<>();
private final Lock initLock = new ReentrantLock();
private volatile boolean migrationProcessed;
private volatile boolean isHighPriorityProcessing;
@Getter
private QueueConsumerManager<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer;
private PostgresGeneralEdgeEventsDispatcher psqlGeneralEdgeEventsDispatcher;
private ExecutorService consumerExecutor;
@Override
public void onEdgeConnect() {
scheduleMigrationAndProcessing();
}
@Override
public void onEdgeEventUpdate() {}
@Override
public void onEdgeDisconnect() {}
@Override
public void onEdgeRemoval() {
EdgeSessionState state = getState();
String topic = topicService
.buildEdgeEventNotificationsTopicPartitionInfo(state.getTenantId(), state.getEdgeId())
.getTopic();
kafkaAdmin.deleteTopic(topic);
kafkaAdmin.deleteConsumerGroup(topic);
}
@Override
public boolean destroy() {
cancelHighPriorityProcessing();
EdgeSessionState state = getState();
try {
if (consumer != null) {
log.info("[{}][{}] Stopping edge event consumer...", state.getTenantId(), state.getEdgeId());
consumer.stop();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to stop edge event consumer", state.getTenantId(), state.getEdgeId(), e);
return false;
}
consumer = null;
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
consumerExecutor.shutdown();
awaitConsumerTermination();
}
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown edge event consumer executor", state.getTenantId(), state.getEdgeId(), e);
return false;
}
return true;
}
private void scheduleMigrationAndProcessing() {
cancelMigrationAndProcessingInit();
EdgeSessionState state = getSession().getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
if (!sessions.hasByEdgeId(edgeId)) {
log.debug("[{}] Session was removed and edge event consumer must not be started [{}]",
tenantId, edgeId.getId());
return;
}
ScheduledFuture<?> initMigrationAndProcessingTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> {
log.info("[{}] Edge event processing task started for edge [{}]", tenantId, edgeId);
initLock.lock();
try {
processRemainingPostgresEventsIfRequired(edgeId);
if (migrationProcessed) {
initAndLaunchConsumerOrRetry(tenantId, edgeId, state);
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
}
finally {
initLock.unlock();
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
initMigrationAndProcessingFutureRef.set(initMigrationAndProcessingTask);
}
private void initAndLaunchConsumerOrRetry(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (initAndLaunchConsumer(tenantId, edgeId, state)) {
launchProcessingOfHighPriorityEvents(tenantId, edgeId);
} else {
scheduleMigrationAndProcessing();
}
}
private void launchProcessingOfHighPriorityEvents(TenantId tenantId, EdgeId edgeId) {
cancelHighPriorityProcessing();
ScheduledFuture<?> highPriorityProcessingTask = ctx.getEdgeEventProcessingExecutorService().scheduleAtFixedRate(() -> {
try {
isHighPriorityProcessing = true;
session.processHighPriorityEvents();
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
} finally {
isHighPriorityProcessing = false;
}
}, NO_INITIAL_DELAY_VALUE, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
highPriorityProcessingFutureRef.set(highPriorityProcessingTask);
}
private boolean initAndLaunchConsumer(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) {
log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return false;
}
if (isConsumerInitializationRequired()) {
initConsumerAndExecutor(tenantId, edgeId, state);
}
return true;
}
private void initConsumerAndExecutor(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) {
try {
if (consumerExecutor != null && !consumerExecutor.isShutdown()) {
try {
consumerExecutor.shutdown();
awaitConsumerTermination();
} catch (Exception e) {
log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edgeId, e);
}
}
this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer"));
this.consumer = QueueConsumerManager.<TbProtoQueueMsg<ToEdgeEventNotificationMsg>>builder()
.name("TB Edge events [" + edgeId + "]")
.msgPackProcessor(this::processMsgs)
.pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval())
.consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edgeId))
.consumerExecutor(consumerExecutor)
.threadPrefix("edge-events-" + edgeId)
.build();
consumer.subscribe();
consumer.launch();
} catch (Exception e) {
destroy();
log.warn("[{}][{}] Failed to start edge event consumer", state.getSessionId(), edgeId, e);
}
}
private boolean isConsumerInitializationRequired() {
return consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped());
}
private void processMsgs(List<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> msgs, TbQueueConsumer<TbProtoQueueMsg<ToEdgeEventNotificationMsg>> consumer) {
EdgeSessionState state = getSession().getState();
TenantId tenantId = state.getTenantId();
EdgeId edgeId = state.getEdgeId();
log.trace("[{}][{}] starting processing edge events", tenantId, edgeId);
if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) {
log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " +
"connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration",
tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing);
return;
}
List<EdgeEvent> edgeEvents = new ArrayList<>();
for (TbProtoQueueMsg<ToEdgeEventNotificationMsg> msg : msgs) {
EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg());
edgeEvents.add(edgeEvent);
}
List<DownlinkMsg> downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, edgeEvents);
boolean isInterrupted = true;
try {
isInterrupted = session.sendDownlinkMsgsPack(downlinkMsgsPack).get();
if (isInterrupted) {
log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edgeId);
}
} catch (Exception e) {
log.error("[{}][{}] Failed to process downlink messages", tenantId, edgeId, e);
}
if (!isInterrupted) {
consumer.commit();
}
}
private void cancelMigrationAndProcessingInit() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge migration & processing init for edge", state != null ? state.getEdgeId() : "unknown");
cancelFuture(initMigrationAndProcessingFutureRef);
}
private void cancelHighPriorityProcessing() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling high priority processing task for edge", state != null ? state.getEdgeId() : "unknown");
cancelFuture(highPriorityProcessingFutureRef);
}
private void cancelFuture(AtomicReference<? extends Future<?>> futureReference) {
Future<?> f = futureReference.getAndSet(null);
if (f != null && !f.isCancelled() && !f.isDone()) {
f.cancel(true);
}
}
private void processRemainingPostgresEventsIfRequired(EdgeId edgeId) throws Exception {
log.debug("Processing remaining postgres edge events for edge [{}]", edgeId);
if (psqlGeneralEdgeEventsDispatcher == null) {
psqlGeneralEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx);
}
if (migrationProcessed) {
return;
}
Boolean hasMorePostgresEvents = psqlGeneralEdgeEventsDispatcher.processNewEvents().get();
if (Boolean.TRUE.equals(hasMorePostgresEvents)) {
log.info("Migration still in progress for edge [{}]", edgeId.getId());
scheduleMigrationAndProcessing();
} else if (Boolean.FALSE.equals(hasMorePostgresEvents)) {
log.info("Migration completed for edge [{}]", edgeId.getId());
migrationProcessed = true;
}
}
private void awaitConsumerTermination() {
EdgeSessionState state = getState();
try {
if (!consumerExecutor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) {
consumerExecutor.shutdownNow();
}
} catch (InterruptedException ie) {
log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", state.getTenantId(), state.getEdgeId());
Thread.currentThread().interrupt();
}
}
}

183
application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java

@ -0,0 +1,183 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.service.edge.rpc.session.manager;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.edge.EdgeEvent;
import org.thingsboard.server.common.data.id.EdgeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.service.edge.rpc.EdgeSessionState;
import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher;
import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
@Service
@Slf4j
@ConditionalOnExpression("'${queue.type:null}'!='kafka'")
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@RequiredArgsConstructor
public class PostgresBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager {
private final EdgeSessionsHolder edgeSessions;
private final Lock newEventsLock = new ReentrantLock();
private final AtomicReference<ScheduledFuture<?>> edgeEventCheckFutureRef = new AtomicReference<>();
private PostgresGeneralEdgeEventsDispatcher generalEdgeEventsDispatcher;
private volatile boolean hasNewEvents;
@Override
public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) {
super.addEventToHighPriorityQueue(edgeEvent);
markHasNewEvents();
}
@Override
public void onEdgeConnect() {
markHasNewEvents();
scheduleEdgeEventsCheck();
}
@Override
public void onEdgeEventUpdate() {
markHasNewEvents();
}
@Override
public void onEdgeDisconnect() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
}
@Override
public void onEdgeRemoval() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
}
@Override
public boolean destroy() {
markHasNoEvents();
cancelScheduleEdgeEventsCheck();
return true;
}
private void scheduleEdgeEventsCheck() {
cancelScheduleEdgeEventsCheck();
EdgeId edgeId = getState().getEdgeId();
TenantId tenantId = getState().getTenantId();
if (!edgeSessions.hasByEdgeId(edgeId)) {
log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]",
tenantId, edgeId.getId());
return;
}
ScheduledFuture<?> edgeEventCheckTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> {
try {
newEventsLock.lock();
try {
if (!hasNewEvents) {
scheduleEdgeEventsCheck();
return;
}
log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId());
hasNewEvents = false;
session.processHighPriorityEvents();
processEdgeEventsWithCallback(tenantId, edgeId);
} finally {
newEventsLock.unlock();
}
} catch (Exception e) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e);
}
}, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS);
edgeEventCheckFutureRef.set(edgeEventCheckTask);
log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId());
}
private void processEdgeEventsWithCallback(TenantId tenantId, EdgeId edgeId) throws Exception {
Futures.addCallback(processEdgeEvents(), new FutureCallback<>() {
@Override
public void onSuccess(Boolean newEventsAdded) {
if (Boolean.TRUE.equals(newEventsAdded)) {
log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId());
hasNewEvents = true;
}
scheduleEdgeEventsCheck();
}
@Override
public void onFailure(Throwable t) {
log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, t);
scheduleEdgeEventsCheck();
}
}, ctx.getGrpcCallbackExecutorService());
}
public ListenableFuture<Boolean> processEdgeEvents() throws Exception {
if (generalEdgeEventsDispatcher == null) {
generalEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx);
}
return generalEdgeEventsDispatcher.processNewEvents();
}
private void cancelScheduleEdgeEventsCheck() {
EdgeSessionState state = getState();
log.trace("[{}] cancelling edge event check for edge", state != null ? state.getEdgeId() : "unknown");
ScheduledFuture<?> sf = edgeEventCheckFutureRef.getAndSet(null);
if (sf != null && !sf.isCancelled() && !sf.isDone()) {
sf.cancel(true);
}
}
private void markHasNoEvents() {
markHasEvents(false);
}
private void markHasNewEvents() {
markHasEvents(true);
}
private void markHasEvents(boolean newEventsPresent) {
newEventsLock.lock();
try {
if (hasNewEvents != newEventsPresent) {
EdgeSessionState state = getState();
if (state != null) {
log.trace("[{}] set session new events flag to {} [{}]", state.getTenantId(), newEventsPresent, state.getEdgeId());
}
hasNewEvents = newEventsPresent;
}
} finally {
newEventsLock.unlock();
}
}
}

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

@ -230,12 +230,15 @@ security:
# - Widgets loading external resources (images, fonts, scripts)
# - Dashboard embedding via iframes (if frame-ancestors is restrictive)
# Use 'report-only: true' first to test the impact before enforcing.
# Example value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'"
# The default value covers core ThingsBoard functionality including OpenStreetMap, ArcGIS maps,
# solution template previews, WebSocket telemetry, and chart Web Workers.
# NOTE: Google Maps requires adding Google domains to script-src, which is not included by default.
# To add Google Maps support, append to script-src: https://maps.googleapis.com https://maps.gstatic.com
content-security-policy:
# Enable/disable Content-Security-Policy header. Mitigates XSS and data injection attacks
enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}"
# Full CSP directive string
value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:}"
# Full CSP directive string. The default value is a working policy for most ThingsBoard deployments
value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://img.thingsboard.io https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.arcgis.com; font-src 'self' data:; connect-src 'self' ws: wss: https://*.arcgis.com https://*.arcgisonline.com; worker-src 'self' blob:; frame-ancestors 'self'}"
# If true, uses Content-Security-Policy-Report-Only header instead — the browser
# reports violations but does not enforce them. Use for testing before enforcing.
report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}"

168
application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java

@ -0,0 +1,168 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AiModel;
import org.thingsboard.client.model.OpenAiChatModelConfig;
import org.thingsboard.client.model.OpenAiProviderConfig;
import org.thingsboard.client.model.PageDataAiModel;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class AIModelApiClientTest extends AbstractApiClientTest {
private static final String AI_PREFIX = "AiTest_";
@Test
public void testSaveAndGetAiModel() throws Exception {
long ts = System.currentTimeMillis();
String name = AI_PREFIX + "save_" + ts;
AiModel model = buildAiModel(name, "gpt-4o", 0.7);
AiModel saved = client.saveAiModel(model);
assertNotNull(saved);
assertNotNull(saved.getId());
assertEquals(name, saved.getName());
assertNotNull(saved.getConfiguration());
// get by id
AiModel fetched = client.getAiModelById(saved.getId().getId());
assertNotNull(fetched);
assertEquals(name, fetched.getName());
assertEquals(saved.getId().getId(), fetched.getId().getId());
}
@Test
public void testGetAiModelById() throws Exception {
long ts = System.currentTimeMillis();
AiModel saved = createAiModel("getbyid_" + ts);
AiModel fetched = client.getAiModelById(saved.getId().getId());
assertNotNull(fetched);
assertEquals(saved.getName(), fetched.getName());
assertEquals(saved.getId().getId(), fetched.getId().getId());
}
@Test
public void testUpdateAiModel() throws Exception {
long ts = System.currentTimeMillis();
AiModel saved = createAiModel("update_" + ts);
saved.setName(AI_PREFIX + "updated_" + ts);
OpenAiChatModelConfig updatedConfig = new OpenAiChatModelConfig();
updatedConfig.setModelId("gpt-4o-mini");
updatedConfig.setTemperature(0.3);
updatedConfig.setMaxOutputTokens(2048);
updatedConfig.setMaxRetries(50);
OpenAiProviderConfig providerConfig = new OpenAiProviderConfig();
providerConfig.setApiKey("test-api-key");
providerConfig.setBaseUrl("https://api.openai.com/v1");
updatedConfig.setProviderConfig(providerConfig);
updatedConfig.setProvider("OPENAI");
saved.setConfiguration(updatedConfig);
AiModel updated = client.saveAiModel(saved);
assertNotNull(updated);
assertEquals(saved.getId().getId(), updated.getId().getId());
assertEquals(AI_PREFIX + "updated_" + ts, updated.getName());
}
@Test
public void testDeleteAiModel() throws Exception {
long ts = System.currentTimeMillis();
AiModel saved = createAiModel("delete_" + ts);
UUID modelId = saved.getId().getId();
client.getAiModelById(modelId);
Boolean deleted = client.deleteAiModelById(modelId);
assertTrue(deleted);
assertReturns404(() -> client.getAiModelById(modelId));
}
@Test
public void testGetAiModels() throws Exception {
long ts = System.currentTimeMillis();
for (int i = 0; i < 3; i++) {
createAiModel("list_" + ts + "_" + i);
}
PageDataAiModel page = client.getAiModels(100, 0, AI_PREFIX + "list_" + ts, null, null);
assertNotNull(page);
assertEquals(3, page.getTotalElements().intValue());
for (AiModel m : page.getData()) {
assertTrue(m.getName().startsWith(AI_PREFIX + "list_" + ts));
}
}
@Test
public void testGetAiModelById_notFound() {
UUID nonExistentId = UUID.randomUUID();
assertReturns404(() -> client.getAiModelById(nonExistentId));
}
@Test
public void testGetAiModelsPagination() throws Exception {
long ts = System.currentTimeMillis();
for (int i = 0; i < 5; i++) {
createAiModel("paged_" + ts + "_" + i);
}
PageDataAiModel page1 = client.getAiModels(2, 0, AI_PREFIX + "paged_" + ts, null, null);
assertNotNull(page1);
assertEquals(5, page1.getTotalElements().intValue());
assertEquals(3, page1.getTotalPages().intValue());
assertEquals(2, page1.getData().size());
assertTrue(page1.getHasNext());
PageDataAiModel lastPage = client.getAiModels(2, 2, AI_PREFIX + "paged_" + ts, null, null);
assertEquals(1, lastPage.getData().size());
assertFalse(lastPage.getHasNext());
}
private AiModel buildAiModel(String name, String modelId, double temperature) {
OpenAiChatModelConfig config = new OpenAiChatModelConfig();
config.setModelId(modelId);
config.setTemperature(temperature);
config.setMaxRetries(50);
OpenAiProviderConfig openAiProviderConfig = new OpenAiProviderConfig();
openAiProviderConfig.setApiKey("test-api-key");
openAiProviderConfig.setBaseUrl("https://api.openai.com/v1");
config.setProviderConfig(openAiProviderConfig);
config.setProvider("OPENAI");
AiModel model = new AiModel();
model.setName(name);
model.setConfiguration(config);
return model;
}
private AiModel createAiModel(String suffix) throws Exception {
return client.saveAiModel(buildAiModel(AI_PREFIX + suffix, "gpt-4o", 0.7));
}
}

127
application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java

@ -0,0 +1,127 @@
/**
* Copyright © 2016-2026 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.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.junit.After;
import org.junit.Before;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.ThingsboardClient;
import org.thingsboard.client.model.ActivateUserRequest;
import org.thingsboard.client.model.Authority;
import org.thingsboard.client.model.JwtPair;
import org.thingsboard.client.model.User;
import org.thingsboard.client.model.UserId;
import org.thingsboard.server.common.data.util.ThrowingRunnable;
import org.thingsboard.server.controller.AbstractControllerTest;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@Slf4j
public abstract class AbstractApiClientTest extends AbstractControllerTest {
protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
protected static final ObjectMapper MAPPER = new ObjectMapper();
protected static final String TEST_PREFIX = "ApiClientTestDevice_";
protected static final String TEST_PREFIX_2 = "ApiClientTestDevice2_";
protected static final String CUSTOMER_USERNAME = "javaClientCustomer@thingsboard.org";
protected static final String TENANT_ADMIN_USERNAME = "javaClientTenant@thingsboard.org";
protected static final String TEST_PASSWORD = "password123";
protected ThingsboardClient client;
// FQN for Tenant/Customer to avoid collision with AbstractWebTest fields
protected org.thingsboard.client.model.Tenant savedClientTenant;
protected User clientTenantAdmin;
protected org.thingsboard.client.model.Customer savedClientCustomer;
protected User savedClientCustomerUser;
@Before
public void setUpJavaClient() throws Exception {
client = ThingsboardClient.builder()
.url("http://localhost:" + wsPort)
.build();
client.login("sysadmin@thingsboard.org", "sysadmin");
org.thingsboard.client.model.Tenant tenant = new org.thingsboard.client.model.Tenant();
tenant.setTitle("Java client test tenant");
savedClientTenant = client.saveTenant(tenant);
clientTenantAdmin = new User();
clientTenantAdmin.setAuthority(Authority.TENANT_ADMIN);
clientTenantAdmin.setTenantId(savedClientTenant.getId());
clientTenantAdmin.setEmail(TENANT_ADMIN_USERNAME);
clientTenantAdmin = client.saveUser(clientTenantAdmin, "false");
activateUserAndAuthorize(clientTenantAdmin);
org.thingsboard.client.model.Customer customer = new org.thingsboard.client.model.Customer();
customer.setTitle("Java client test customer");
customer.setTenantId(savedClientTenant.getId());
savedClientCustomer = client.saveCustomer(customer, null, null, null);
User customerUser = new User();
customerUser.setAuthority(Authority.CUSTOMER_USER);
customerUser.setTenantId(savedClientTenant.getId());
customerUser.setCustomerId(savedClientCustomer.getId());
customerUser.setEmail(CUSTOMER_USERNAME);
savedClientCustomerUser = client.saveUser(customerUser, "false");
activateUser(savedClientCustomerUser.getId(), "password123", false);
}
@After
public void tearDownJavaClient() {
client.login("sysadmin@thingsboard.org", "sysadmin");
client.deleteTenant(savedClientTenant.getId().getId().toString());
}
protected String getBaseUrl() {
return "http://localhost:" + wsPort;
}
protected void activateUserAndAuthorize(User user) throws ApiException {
JwtPair jwtPair = activateUser(user.getId(), TEST_PASSWORD, false);
client.setToken(jwtPair.getToken());
}
protected JwtPair activateUser(UserId userId, String password, boolean sendActivationMail) throws ApiException {
ActivateUserRequest activateRequest = new ActivateUserRequest();
activateRequest.setActivateToken(getActivateToken(userId));
activateRequest.setPassword(password);
return client.activateUser(activateRequest, sendActivationMail);
}
protected String getActivateToken(UserId userId) throws ApiException {
String activateTokenRegex = "/api/noauth/activate?activateToken=";
String activationLink = client.getActivationLink(userId.getId().toString());
return activationLink.substring(activationLink.lastIndexOf(activateTokenRegex) + activateTokenRegex.length());
}
protected void assertReturns404(ThrowingRunnable operation) {
try {
operation.run();
fail("Expected ApiException with 404 status code");
} catch (ApiException exception) {
assertEquals("Expected 404 status code but got " + exception.getCode(),
404, exception.getCode());
} catch (Exception e) {
fail("Expected ApiException but got " + e.getClass().getName() + ": " + e.getMessage());
}
}
}

99
application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java

@ -0,0 +1,99 @@
/**
* Copyright © 2016-2026 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.client;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.thingsboard.client.model.AdminSettings;
import org.thingsboard.client.model.FeaturesInfo;
import org.thingsboard.client.model.JwtSettings;
import org.thingsboard.client.model.SecuritySettings;
import org.thingsboard.client.model.SystemInfo;
import org.thingsboard.client.model.UpdateMessage;
import org.thingsboard.server.dao.service.DaoSqlTest;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class AdminApiClientTest extends AbstractApiClientTest {
@Test
public void testAdminSettingsLifecycle() throws Exception {
// authenticate as sysadmin for admin settings management
client.login("sysadmin@thingsboard.org", "sysadmin");
// get mail settings
AdminSettings mailSettings = client.getAdminSettings("mail");
assertNotNull(mailSettings);
assertNotNull(mailSettings.getKey());
assertEquals("mail", mailSettings.getKey());
assertNotNull(mailSettings.getJsonValue());
// get general settings
AdminSettings generalSettings = client.getAdminSettings("general");
assertNotNull(generalSettings);
assertEquals("general", generalSettings.getKey());
assertNotNull(generalSettings.getJsonValue());
assertNotNull(generalSettings.getJsonValue().get("baseUrl").asText());
// update general settings and restore
((ObjectNode) generalSettings.getJsonValue()).put("prohibitDifferentUrl", true);
AdminSettings updatedGeneralSettings = client.saveAdminSettings(generalSettings);
assertTrue(updatedGeneralSettings.getJsonValue().get("prohibitDifferentUrl").asBoolean());
// get security settings
SecuritySettings securitySettings = client.getSecuritySettings();
assertNotNull(securitySettings);
assertNotNull(securitySettings.getPasswordPolicy());
Integer originalMaxAttempts = securitySettings.getMaxFailedLoginAttempts();
// update security settings
securitySettings.setMaxFailedLoginAttempts(10);
SecuritySettings updatedSecurity = client.saveSecuritySettings(securitySettings);
assertNotNull(updatedSecurity);
assertEquals(10, updatedSecurity.getMaxFailedLoginAttempts().intValue());
// restore original security settings
updatedSecurity.setMaxFailedLoginAttempts(originalMaxAttempts);
client.saveSecuritySettings(updatedSecurity);
// get JWT settings
JwtSettings jwtSettings = client.getJwtSettings();
assertNotNull(jwtSettings);
assertNotNull(jwtSettings.getTokenExpirationTime());
assertNotNull(jwtSettings.getRefreshTokenExpTime());
assertEquals("thingsboard.io", jwtSettings.getTokenIssuer());
assertNotNull(jwtSettings.getTokenSigningKey());
// get system info
SystemInfo systemInfo = client.getSystemInfo();
assertNotNull(systemInfo);
// get features info
FeaturesInfo featuresInfo = client.getFeaturesInfo();
assertNotNull(featuresInfo);
assertFalse(featuresInfo.getSmsEnabled());
assertFalse(featuresInfo.getOauthEnabled());
// check updates
UpdateMessage updateMessage = client.checkUpdates();
assertNotNull(updateMessage);
}
}

163
application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java

@ -0,0 +1,163 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Alarm;
import org.thingsboard.client.model.AlarmInfo;
import org.thingsboard.client.model.AlarmSeverity;
import org.thingsboard.client.model.AlarmStatus;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntitySubtype;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.PageDataAlarmInfo;
import org.thingsboard.client.model.PageDataEntitySubtype;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class AlarmApiClientTest extends AbstractApiClientTest {
@Test
public void testAlarmLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<Alarm> createdAlarms = new ArrayList<>();
// First, create devices to attach alarms to
Device device1 = new Device();
device1.setName("Device_For_Alarm_" + timestamp + "_1");
device1.setType("default");
Device createdDevice1 = client.saveDevice(device1, null, null, null, null);
Device device2 = new Device();
device2.setName("Device_For_Alarm_" + timestamp + "_2");
device2.setType("thermostat");
Device createdDevice2 = client.saveDevice(device2, null, null, null, null);
// Create 2 alarms (1 for each device)
for (int i = 0; i < 2; i++) {
Alarm alarm = new Alarm();
alarm.setType(((i % 2 == 0) ? "Temperature Alarm" : "Connection Alarm"));
alarm.setSeverity(((i % 2 == 0) ? AlarmSeverity.CRITICAL : AlarmSeverity.WARNING));
alarm.setOriginator((i % 2 == 0) ? createdDevice1.getId() : createdDevice2.getId());
Alarm createdAlarm = client.saveAlarm(alarm);
assertNotNull(createdAlarm);
assertNotNull(createdAlarm.getId());
assertEquals(alarm.getType(), createdAlarm.getType());
assertEquals(alarm.getSeverity(), createdAlarm.getSeverity());
createdAlarms.add(createdAlarm);
}
// Get all alarms
PageDataAlarmInfo allAlarms = client.getAllAlarms(100, 0, null, null, null, null, null, null, null, null, null);
assertNotNull(allAlarms);
assertNotNull(allAlarms.getData());
int initialSize = allAlarms.getData().size();
assertEquals("Expected at least 2 alarms, but got " + initialSize, 2, initialSize);
// Get alarms by entity (device1)
PageDataAlarmInfo device1Alarms = client.getAlarmsV2(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), 100, 0, null, null, null, null, null, null, null, null, null);
assertNotNull(device1Alarms);
assertEquals("Expected 1 alarms for device1", 1, device1Alarms.getData().size());
// Get alarm by id
Alarm searchAlarm = createdAlarms.get(0);
Alarm fetchedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString());
assertEquals(searchAlarm.getType(), fetchedAlarm.getType());
assertEquals(searchAlarm.getSeverity(), fetchedAlarm.getSeverity());
// Get alarm info
AlarmInfo alarmInfo = client.getAlarmInfoById(searchAlarm.getId().getId().toString());
assertNotNull(alarmInfo);
assertEquals(searchAlarm.getId().getId(), alarmInfo.getId().getId());
// Acknowledge alarm
client.ackAlarm(searchAlarm.getId().getId().toString());
// Verify alarm is acknowledged
Alarm ackedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString());
assertEquals(AlarmStatus.ACTIVE_ACK, ackedAlarm.getStatus());
// Clear alarm
client.clearAlarm(searchAlarm.getId().getId().toString());
// Verify alarm is cleared
Alarm clearedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString());
assertEquals(AlarmStatus.CLEARED_ACK, clearedAlarm.getStatus());
// Get highest severity alarm for device
AlarmSeverity highestSeverity = client.getHighestAlarmSeverity(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), null, null, null);
assertNotNull(highestSeverity);
assertEquals(AlarmSeverity.CRITICAL, highestSeverity);
// Assign alarm to customer
client.assignAlarm(createdAlarms.get(0).getId().getId().toString(), clientTenantAdmin.getId().getId().toString());
// Verify assignment
Alarm assignedAlarm = client.getAlarmById(createdAlarms.get(0).getId().getId().toString());
assertEquals(clientTenantAdmin.getId().getId(), assignedAlarm.getAssigneeId().getId());
// Unassign alarm
client.unassignAlarm(createdAlarms.get(0).getId().getId().toString());
// Verify unassignment
Alarm unassignedAlarm = client.getAlarmById(createdAlarms.get(0).getId().getId().toString());
assertNull(unassignedAlarm.getAssigneeId());
// Get alarm types
PageDataEntitySubtype pageDataEntitySubtype = client.getAlarmTypes(100, 0, null, null);
assertEquals(2, pageDataEntitySubtype.getData().size());
List<String> alarmTypes = pageDataEntitySubtype.getData().stream()
.map(EntitySubtype::getType)
.collect(Collectors.toList());
assertTrue(alarmTypes.containsAll(List.of("Temperature Alarm", "Connection Alarm")));
// Get alarms V2 (alternative endpoint)
PageDataAlarmInfo alarmsV2 = client.getAlarmsV2(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), 100, 0, null, null, null, null, null, null, null, null, null);
assertNotNull(alarmsV2);
assertEquals(1, alarmsV2.getData().size());
// Get all alarms V2
PageDataAlarmInfo allAlarmsV2 = client.getAllAlarmsV2(100, 0, null, null, null, null, null, null, null, null, null);
assertEquals(2, allAlarmsV2.getData().size());
// Delete alarm
UUID alarmToDeleteId = createdAlarms.get(0).getId().getId();
client.deleteAlarm(alarmToDeleteId.toString());
// Verify the alarm is deleted (should return 404)
assertReturns404(() ->
client.getAlarmById(alarmToDeleteId.toString())
);
// Verify count after deletion
PageDataAlarmInfo alarmsAfterDelete = client.getAllAlarms(100, 0, null, null, null, null, null, null, null, null, null);
assertEquals(initialSize - 1, alarmsAfterDelete.getData().size());
}
}

106
application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java

@ -0,0 +1,106 @@
/**
* Copyright © 2016-2026 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.client;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Test;
import org.thingsboard.client.model.Alarm;
import org.thingsboard.client.model.AlarmComment;
import org.thingsboard.client.model.AlarmCommentInfo;
import org.thingsboard.client.model.AlarmSeverity;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.PageDataAlarmCommentInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class AlarmCommentApiClientTest extends AbstractApiClientTest {
@Test
public void testAlarmComments() throws Exception {
long timestamp = System.currentTimeMillis();
// Create device for alarm
Device device = new Device();
device.setName("Device_For_Comments_" + timestamp);
device.setType("default");
Device createdDevice = client.saveDevice(device, null, null, null, null);
// Create alarm
Alarm alarm = new Alarm();
alarm.setType("Temperature Alarm");
alarm.setSeverity(AlarmSeverity.CRITICAL);
alarm.setOriginator(createdDevice.getId());
Alarm createdAlarm = client.saveAlarm(alarm);
String alarmId = createdAlarm.getId().getId().toString();
List<AlarmComment> createdComments = new ArrayList<>();
// Create multiple comments
for (int i = 0; i < 5; i++) {
AlarmComment alarmComment = new AlarmComment();
String message = "Test comment #" + i + " at " + timestamp;
ObjectNode comment = OBJECT_MAPPER.createObjectNode().put("message", message);
alarmComment.setComment(comment);
AlarmComment commentInfo = client.saveAlarmComment(alarmId, alarmComment);
assertNotNull(commentInfo);
assertNotNull(commentInfo.getId());
JsonNode commentValue = commentInfo.getComment();
assertEquals(message, commentValue.get("message").asText());
assertNotNull(commentInfo.getCreatedTime());
createdComments.add(commentInfo);
}
// Get all comments for the alarm
PageDataAlarmCommentInfo allComments = client.getAlarmComments(alarmId, 100, 0, null, null);
assertEquals("Expected 5 comments", 5, allComments.getData().size());
// Update a comment
AlarmComment commentToUpdate = createdComments.get(2);
JsonNode comment = commentToUpdate.getComment();
((ObjectNode) comment).put("message", "New comment");
commentToUpdate.setComment(comment);
AlarmComment updatedComment = client.saveAlarmComment(alarmId, commentToUpdate);
assertEquals("New comment", updatedComment.getComment().get("message").asText());
// Delete a comment
UUID commentToDeleteId = createdComments.get(0).getId().getId();
client.deleteAlarmComment(alarmId, commentToDeleteId.toString());
// Verify comment was updated to "deleted"
PageDataAlarmCommentInfo commentsAfterDelete = client.getAlarmComments(alarmId, 100, 0, null, null);
List<AlarmCommentInfo> data = commentsAfterDelete.getData();
AlarmCommentInfo deletedComment = data.stream()
.filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId))
.findFirst()
.get();
assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText());
}
}

117
application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java

@ -0,0 +1,117 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.ApiKey;
import org.thingsboard.client.model.ApiKeyInfo;
import org.thingsboard.client.model.PageDataApiKeyInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class ApiKeyApiClientTest extends AbstractApiClientTest {
@Test
public void testApiKeyLifecycle() throws Exception {
String userId = clientTenantAdmin.getId().getId().toString();
ApiKeyInfo request = new ApiKeyInfo();
request.setDescription("Test API key");
request.setUserId(clientTenantAdmin.getId());
request.setEnabled(true);
ApiKey created = client.saveApiKey(request);
assertNotNull(created);
assertNotNull(created.getId());
assertNotNull(created.getValue());
assertFalse(created.getValue().isBlank());
assertEquals("Test API key", created.getDescription());
UUID keyId = created.getId().getId();
PageDataApiKeyInfo keysPage = client.getUserApiKeys(userId, 100, 0, null, null, null);
assertNotNull(keysPage);
assertNotNull(keysPage.getData());
assertTrue("Newly created API key should appear in user's key list",
keysPage.getData().stream()
.anyMatch(k -> k.getId().getId().equals(keyId)));
client.deleteApiKey(keyId);
PageDataApiKeyInfo keysAfterDelete = client.getUserApiKeys(userId, 100, 0, null, null, null);
assertTrue("Deleted API key should not appear in user's key list",
keysAfterDelete.getData().stream()
.noneMatch(k -> k.getId().getId().equals(keyId)));
}
@Test
public void testEnableDisableApiKey() throws Exception {
ApiKeyInfo request = new ApiKeyInfo();
request.setDescription("Enable/disable test key");
request.setUserId(clientTenantAdmin.getId());
request.setEnabled(true);
ApiKey created = client.saveApiKey(request);
assertNotNull(created);
UUID keyId = created.getId().getId();
ApiKeyInfo disabled = client.enableApiKey(keyId, false);
assertNotNull(disabled);
assertEquals(Boolean.FALSE, disabled.getEnabled());
ApiKeyInfo enabled = client.enableApiKey(keyId, true);
assertNotNull(enabled);
assertEquals(Boolean.TRUE, enabled.getEnabled());
client.deleteApiKey(keyId);
}
@Test
public void testGetUserApiKeys() throws Exception {
String userId = clientTenantAdmin.getId().getId().toString();
int initialCount = client.getUserApiKeys(userId, 100, 0, null, null, null)
.getData().size();
UUID[] createdIds = new UUID[3];
for (int i = 0; i < 3; i++) {
ApiKeyInfo request = new ApiKeyInfo();
request.setDescription("Paging test key " + i);
request.setUserId(clientTenantAdmin.getId());
createdIds[i] = client.saveApiKey(request).getId().getId();
}
PageDataApiKeyInfo afterCreate = client.getUserApiKeys(userId, 100, 0, null, null, null);
assertEquals(initialCount + 3, afterCreate.getData().size());
assertEquals(Long.valueOf(initialCount + 3), afterCreate.getTotalElements());
PageDataApiKeyInfo page1 = client.getUserApiKeys(userId, 2, 0, null, null, null);
assertEquals(2, page1.getData().size());
assertTrue(page1.getHasNext());
for (UUID id : createdIds) {
client.deleteApiKey(id);
}
}
}

84
application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java

@ -0,0 +1,84 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Asset;
import org.thingsboard.client.model.PageDataAsset;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class AssetApiClientTest extends AbstractApiClientTest {
@Test
public void testAssetLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<Asset> createdAssets = new ArrayList<>();
// create 20 assets
for (int i = 0; i < 20; i++) {
Asset asset = new Asset();
String assetName = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i;
asset.setName(assetName);
asset.setLabel("Test Asset " + i);
asset.setType(((i % 2 == 0) ? "default" : "building"));
Asset createdAsset = client.saveAsset(asset, null, null, null);
assertNotNull(createdAsset);
assertNotNull(createdAsset.getId());
assertEquals(assetName, createdAsset.getName());
createdAssets.add(createdAsset);
}
// find all, check count
PageDataAsset allAssets = client.getTenantAssets(100, 0, null, null, null, null);
assertNotNull(allAssets);
assertNotNull(allAssets.getData());
int initialSize = allAssets.getData().size();
assertEquals("Expected at least 20 assets, but got " + allAssets.getData().size(), 20, initialSize);
//find all with search text, check count
PageDataAsset allAssetsBySearchText = client.getTenantAssets(100, 0, null, TEST_PREFIX_2, null, null);
assertEquals("Expected exactly 10 test assets", 10, allAssetsBySearchText.getData().size());
// find by id
Asset searchAsset = createdAssets.get(10);
Asset asset = client.getAssetById(searchAsset.getId().getId().toString());
assertEquals(searchAsset.getName(), asset.getName());
// delete asset
UUID assetToDeleteId = createdAssets.get(0).getId().getId();
client.deleteAsset(assetToDeleteId.toString());
// Verify the asset is deleted
PageDataAsset assetsAfterDelete = client.getTenantAssets(100, 0, null, null, null, null);
assertEquals(initialSize - 1, assetsAfterDelete.getData().size());
assertReturns404(() ->
client.getAssetById(assetToDeleteId.toString())
);
}
}

135
application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java

@ -0,0 +1,135 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AssetProfile;
import org.thingsboard.client.model.AssetProfileInfo;
import org.thingsboard.client.model.EntityInfo;
import org.thingsboard.client.model.PageDataAssetProfile;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class AssetProfileApiClientTest extends AbstractApiClientTest {
@Test
public void testAssetProfileLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<AssetProfile> createdProfiles = new ArrayList<>();
// Get initial count (there should be a default profile)
PageDataAssetProfile initialProfiles = client.getAssetProfiles(100, 0, null, null, null);
assertNotNull(initialProfiles);
int initialSize = initialProfiles.getData().size();
assertTrue("Expected at least 1 default asset profile", initialSize == 1);
// Get default asset profile info
AssetProfileInfo defaultProfileInfo = client.getDefaultAssetProfileInfo();
assertNotNull(defaultProfileInfo);
assertEquals(defaultProfileInfo.getName(), "default");
// Create multiple asset profiles
for (int i = 0; i < 5; i++) {
AssetProfile profile = new AssetProfile();
profile.setName("Test Asset Profile " + timestamp + "_" + i);
profile.setDescription("Test description " + i);
AssetProfile created = client.saveAssetProfile(profile);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(profile.getName(), created.getName());
assertEquals(profile.getDescription(), created.getDescription());
assertFalse(created.getDefault());
createdProfiles.add(created);
}
// Find all, check count
PageDataAssetProfile allProfiles = client.getAssetProfiles(100, 0, null, null, null);
assertNotNull(allProfiles);
assertEquals(initialSize + 5, allProfiles.getData().size());
// Find all with text search
PageDataAssetProfile filteredProfiles = client.getAssetProfiles(100, 0, "Test Asset Profile " + timestamp, null, null);
assertEquals(5, filteredProfiles.getData().size());
// Get by id
AssetProfile searchProfile = createdProfiles.get(2);
AssetProfile fetchedProfile = client.getAssetProfileById(searchProfile.getId().getId().toString(), false);
assertEquals(searchProfile.getName(), fetchedProfile.getName());
assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription());
// Update asset profile
fetchedProfile.setDescription("Updated description");
AssetProfile updatedProfile = client.saveAssetProfile(fetchedProfile);
assertEquals("Updated description", updatedProfile.getDescription());
assertEquals(fetchedProfile.getName(), updatedProfile.getName());
// Get asset profile info by id
AssetProfileInfo profileInfo = client.getAssetProfileInfoById(searchProfile.getId().getId().toString());
assertNotNull(profileInfo);
assertEquals(searchProfile.getName(), profileInfo.getName());
// Get asset profile infos (paginated)
PageDataAssetProfile profileInfos = client.getAssetProfiles(100, 0, null, null, null);
assertNotNull(profileInfos);
assertEquals(initialSize + 5, profileInfos.getData().size());
// Set a profile as default
AssetProfile profileToSetDefault = createdProfiles.get(1);
AssetProfile newDefault = client.setDefaultAssetProfile(profileToSetDefault.getId().getId().toString());
assertNotNull(newDefault);
assertTrue(newDefault.getDefault());
// Verify default profile info now points to the new default
AssetProfileInfo newDefaultInfo = client.getDefaultAssetProfileInfo();
assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName());
// Get asset profile names
List<EntityInfo> profileNames = client.getAssetProfileNames(false);
assertNotNull(profileNames);
assertEquals(createdProfiles.size() + 1, profileNames.size());
// Delete asset profile (cannot delete the default one, so delete a non-default one)
UUID profileToDeleteId = createdProfiles.get(0).getId().getId();
client.deleteAssetProfile(profileToDeleteId.toString());
// Verify the profile is deleted
assertReturns404(() ->
client.getAssetProfileById(profileToDeleteId.toString(), false));
// Verify count after deletion
PageDataAssetProfile profilesAfterDelete = client.getAssetProfiles(100, 0, null, null, null);
assertEquals(initialSize + 4, profilesAfterDelete.getData().size());
// Restore original default profile
AssetProfile originalDefault = initialProfiles.getData().stream()
.filter(AssetProfile::getDefault)
.findFirst()
.orElseThrow();
client.setDefaultAssetProfile(originalDefault.getId().getId().toString());
}
}

286
application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java

@ -0,0 +1,286 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AlarmCalculatedFieldConfiguration;
import org.thingsboard.client.model.AlarmConditionValueAlarmRuleSchedule;
import org.thingsboard.client.model.AlarmRuleDefinition;
import org.thingsboard.client.model.AlarmRuleSimpleCondition;
import org.thingsboard.client.model.AlarmRuleSpecificTimeSchedule;
import org.thingsboard.client.model.AlarmSeverity;
import org.thingsboard.client.model.Argument;
import org.thingsboard.client.model.ArgumentType;
import org.thingsboard.client.model.CalculatedField;
import org.thingsboard.client.model.CalculatedFieldType;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.PageDataCalculatedField;
import org.thingsboard.client.model.ReferencedEntityKey;
import org.thingsboard.client.model.SimpleCalculatedFieldConfiguration;
import org.thingsboard.client.model.TbelAlarmConditionExpression;
import org.thingsboard.client.model.TimeSeriesOutput;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class CalculatedFieldApiClientTest extends AbstractApiClientTest {
@Test
public void testCalculatedFieldLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<CalculatedField> createdFields = new ArrayList<>();
// create devices to attach calculated fields to
Device device1 = new Device();
device1.setName("CalcFieldDevice1_" + timestamp);
device1.setType("default");
Device createdDevice1 = client.saveDevice(device1, null, null, null, null);
Device device2 = new Device();
device2.setName("CalcFieldDevice2_" + timestamp);
device2.setType("default");
Device createdDevice2 = client.saveDevice(device2, null, null, null, null);
// create calculated fields on device1
for (int i = 0; i < 5; i++) {
CalculatedField cf = new CalculatedField();
cf.setName(TEST_PREFIX + "CalcField_" + timestamp + "_" + i);
cf.setType(CalculatedFieldType.SIMPLE);
cf.setEntityId(createdDevice1.getId());
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument arg = new Argument();
ReferencedEntityKey refKey = new ReferencedEntityKey();
refKey.setKey("temperature");
refKey.setType(ArgumentType.TS_LATEST);
arg.setRefEntityKey(refKey);
config.putArgumentsItem("temp", arg);
config.setExpression("temp * " + (i + 1));
TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("scaledTemp_" + i);
config.setOutput(output);
cf.setConfiguration(config);
CalculatedField created = client.saveCalculatedField(cf);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(cf.getName(), created.getName());
assertEquals(CalculatedFieldType.SIMPLE, created.getType());
createdFields.add(created);
}
// create calculated fields on device2
for (int i = 0; i < 3; i++) {
CalculatedField cf = new CalculatedField();
cf.setName(TEST_PREFIX + "CalcField2_" + timestamp + "_" + i);
cf.setType(CalculatedFieldType.SIMPLE);
cf.setEntityId(createdDevice2.getId());
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration();
Argument arg = new Argument();
ReferencedEntityKey refKey = new ReferencedEntityKey();
refKey.setKey("humidity");
refKey.setType(ArgumentType.TS_LATEST);
arg.setRefEntityKey(refKey);
config.putArgumentsItem("hum", arg);
config.setExpression("hum + " + i);
TimeSeriesOutput output = new TimeSeriesOutput();
output.setName("adjustedHumidity_" + i);
config.setOutput(output);
cf.setConfiguration(config);
CalculatedField created = client.saveCalculatedField(cf);
assertNotNull(created);
createdFields.add(created);
}
// get calculated fields by entity id for device1
PageDataCalculatedField device1Fields = client.getCalculatedFieldsByEntityId(
EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(),
100, 0, CalculatedFieldType.SIMPLE, null, null, null);
assertNotNull(device1Fields);
assertEquals(5, device1Fields.getData().size());
// get calculated fields by entity id for device2
PageDataCalculatedField device2Fields = client.getCalculatedFieldsByEntityId(
EntityType.DEVICE.toString(), createdDevice2.getId().getId().toString(),
100, 0, CalculatedFieldType.SIMPLE, null, null, null);
assertEquals(3, device2Fields.getData().size());
// get by id
CalculatedField searchField = createdFields.get(2);
CalculatedField fetchedField = client.getCalculatedFieldById(searchField.getId().getId().toString());
assertEquals(searchField.getName(), fetchedField.getName());
assertEquals(searchField.getType(), fetchedField.getType());
assertNotNull(fetchedField.getConfiguration());
SimpleCalculatedFieldConfiguration fetchedConfig =
(SimpleCalculatedFieldConfiguration) fetchedField.getConfiguration();
assertEquals("temp * 3", fetchedConfig.getExpression());
// update calculated field
fetchedField.setName(fetchedField.getName() + "_updated");
fetchedConfig.setExpression("temp * 100");
CalculatedField updatedField = client.saveCalculatedField(fetchedField);
assertEquals(fetchedField.getName(), updatedField.getName());
SimpleCalculatedFieldConfiguration updatedConfig =
(SimpleCalculatedFieldConfiguration) updatedField.getConfiguration();
assertEquals("temp * 100", updatedConfig.getExpression());
// delete calculated field
UUID fieldToDeleteId = createdFields.get(0).getId().getId();
client.deleteCalculatedField(fieldToDeleteId.toString());
// verify deletion
assertReturns404(() ->
client.getCalculatedFieldById(fieldToDeleteId.toString())
);
PageDataCalculatedField device1FieldsAfterDelete = client.getCalculatedFieldsByEntityId(
EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(),
100, 0, null, null, null, null);
assertEquals(4, device1FieldsAfterDelete.getData().size());
}
@Test
public void testAlarmCalculatedFieldLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
// create a device to attach the alarm calculated field to
Device device = new Device();
device.setName("AlarmCalcFieldDevice_" + timestamp);
device.setType("default");
Device createdDevice = client.saveDevice(device, null, null, null, null);
// build the alarm calculated field configuration
AlarmCalculatedFieldConfiguration config = new AlarmCalculatedFieldConfiguration();
// argument: temperature time-series
Argument tempArg = new Argument();
ReferencedEntityKey refKey = new ReferencedEntityKey();
refKey.setKey("temperature");
refKey.setType(ArgumentType.TS_LATEST);
tempArg.setRefEntityKey(refKey);
config.putArgumentsItem("temp", tempArg);
// create rule: HIGH_TEMPERATURE when temp > 50 (TBEL expression)
TbelAlarmConditionExpression createExpression = new TbelAlarmConditionExpression();
createExpression.setExpression("return temp > 50;");
AlarmRuleSimpleCondition createCondition = new AlarmRuleSimpleCondition();
createCondition.setExpression(createExpression);
AlarmRuleSpecificTimeSchedule specificTimeSchedule = new AlarmRuleSpecificTimeSchedule().addDaysOfWeekItem(3);
AlarmConditionValueAlarmRuleSchedule schedule = new AlarmConditionValueAlarmRuleSchedule().staticValue(specificTimeSchedule);
createCondition.setSchedule(schedule);
AlarmRuleDefinition createRule = new AlarmRuleDefinition();
createRule.setCondition(createCondition);
createRule.setAlarmDetails("Temperature is too high: ${temp}");
config.setCreateRules(Map.of(
AlarmSeverity.CRITICAL.name(), createRule
));
// clear rule: when temp drops below 30
TbelAlarmConditionExpression clearExpression = new TbelAlarmConditionExpression();
clearExpression.setExpression("return temp < 30;");
AlarmRuleSimpleCondition clearCondition = new AlarmRuleSimpleCondition();
clearCondition.setExpression(clearExpression);
AlarmRuleDefinition clearRule = new AlarmRuleDefinition();
clearRule.setCondition(clearCondition);
config.setClearRule(clearRule);
config.setPropagate(true);
config.setPropagateToOwner(false);
// create calculated field
CalculatedField cf = new CalculatedField();
cf.setName(TEST_PREFIX + "AlarmCalcField_" + timestamp);
cf.setType(CalculatedFieldType.ALARM);
cf.setEntityId(createdDevice.getId());
cf.setConfiguration(config);
CalculatedField created = client.saveCalculatedField(cf);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(cf.getName(), created.getName());
assertEquals(CalculatedFieldType.ALARM, created.getType());
AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) created.getConfiguration();
AlarmConditionValueAlarmRuleSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule();
AlarmRuleSpecificTimeSchedule staticSchedule = (AlarmRuleSpecificTimeSchedule) createdSchedule.getStaticValue();
assertEquals(Set.of(3), staticSchedule.getDaysOfWeek());
// get by id and verify configuration
CalculatedField fetched = client.getCalculatedFieldById(created.getId().getId().toString());
assertNotNull(fetched);
assertEquals(created.getName(), fetched.getName());
assertEquals(CalculatedFieldType.ALARM, fetched.getType());
assertNotNull(fetched.getConfiguration());
AlarmCalculatedFieldConfiguration fetchedConfig =
(AlarmCalculatedFieldConfiguration) fetched.getConfiguration();
assertNotNull(fetchedConfig.getCreateRules());
assertEquals(1, fetchedConfig.getCreateRules().size());
assertTrue(fetchedConfig.getCreateRules().containsKey("CRITICAL"));
assertNotNull(fetchedConfig.getClearRule());
assertEquals(Boolean.TRUE, fetchedConfig.getPropagate());
// update: add a second create rule for CRITICAL_TEMPERATURE
TbelAlarmConditionExpression criticalExpression = new TbelAlarmConditionExpression();
criticalExpression.setExpression("return temp > 80;");
AlarmRuleSimpleCondition criticalCondition = new AlarmRuleSimpleCondition();
criticalCondition.setExpression(criticalExpression);
AlarmRuleDefinition criticalRule = new AlarmRuleDefinition();
criticalRule.setCondition(criticalCondition);
fetchedConfig.putCreateRulesItem(AlarmSeverity.INDETERMINATE.name(), criticalRule);
fetched.setConfiguration(fetchedConfig);
CalculatedField updated = client.saveCalculatedField(fetched);
AlarmCalculatedFieldConfiguration updatedConfig =
(AlarmCalculatedFieldConfiguration) updated.getConfiguration();
assertEquals(2, updatedConfig.getCreateRules().size());
assertTrue(updatedConfig.getCreateRules().containsKey("INDETERMINATE"));
// filter by entity and ALARM type
PageDataCalculatedField deviceFields = client.getCalculatedFieldsByEntityId(
EntityType.DEVICE.toString(), createdDevice.getId().getId().toString(),
100, 0, CalculatedFieldType.ALARM, null, null, null);
assertNotNull(deviceFields);
assertEquals(1, deviceFields.getData().size());
// delete and verify
UUID fieldId = created.getId().getId();
client.deleteCalculatedField(fieldId.toString());
assertReturns404(() -> client.getCalculatedFieldById(fieldId.toString()));
}
}

113
application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java

@ -0,0 +1,113 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Customer;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.PageDataCustomer;
import org.thingsboard.client.model.PageDataDevice;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class CustomerApiClientTest extends AbstractApiClientTest {
@Test
public void testCustomerLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<Customer> createdCustomers = new ArrayList<>();
// create 20 customers
for (int i = 0; i < 20; i++) {
Customer customer = new Customer();
String customerTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i;
customer.setTitle(customerTitle);
customer.setEmail("customer_" + timestamp + "_" + i + "@test.com");
Customer createdCustomer = client.saveCustomer(customer, null, null, null);
assertNotNull(createdCustomer);
assertNotNull(createdCustomer.getId());
assertEquals(customerTitle, createdCustomer.getTitle());
createdCustomers.add(createdCustomer);
}
// find all, check count (includes savedClientCustomer from AbstractApiClientTest setup)
PageDataCustomer allCustomers = client.getCustomers(100, 0, null, null, null);
assertNotNull(allCustomers);
assertNotNull(allCustomers.getData());
int initialSize = allCustomers.getData().size();
assertEquals("Expected 21 customers (20 created + 1 from setup), but got " + initialSize, 21, initialSize);
// find all with search text, check count
PageDataCustomer filteredCustomers = client.getCustomers(100, 0, TEST_PREFIX_2, null, null);
assertEquals("Expected exactly 10 customers matching prefix", 10, filteredCustomers.getData().size());
// find by id
Customer searchCustomer = createdCustomers.get(10);
Customer fetchedCustomer = client.getCustomerById(searchCustomer.getId().getId().toString());
assertEquals(searchCustomer.getTitle(), fetchedCustomer.getTitle());
// find by title
Customer fetchedByTitle = client.getTenantCustomer(searchCustomer.getTitle());
assertEquals(searchCustomer.getId().getId(), fetchedByTitle.getId().getId());
// update customer
fetchedCustomer.setCity("New York");
fetchedCustomer.setCountry("US");
Customer updatedCustomer = client.saveCustomer(fetchedCustomer, null, null, null);
assertEquals("New York", updatedCustomer.getCity());
assertEquals("US", updatedCustomer.getCountry());
// assign device to customer and verify
Device device = new Device();
device.setName("CustomerTestDevice_" + timestamp);
device.setType("default");
Device createdDevice = client.saveDevice(device, null, null, null, null);
String customerId = createdCustomers.get(0).getId().getId().toString();
client.assignDeviceToCustomer(customerId, createdDevice.getId().getId().toString());
PageDataDevice customerDevices = client.getCustomerDevices(customerId, 100, 0, null, null, null, null);
assertEquals(1, customerDevices.getData().size());
assertEquals(createdDevice.getName(), customerDevices.getData().get(0).getName());
// unassign device from customer
client.unassignDeviceFromCustomer(createdDevice.getId().getId().toString());
PageDataDevice devicesAfterUnassign = client.getCustomerDevices(customerId, 100, 0, null, null, null, null);
assertEquals(0, devicesAfterUnassign.getData().size());
// delete customer
UUID customerToDeleteId = createdCustomers.get(0).getId().getId();
client.deleteCustomer(customerToDeleteId.toString());
// verify deletion
PageDataCustomer customersAfterDelete = client.getCustomers(100, 0, null, null, null);
assertEquals(initialSize - 1, customersAfterDelete.getData().size());
assertReturns404(() ->
client.getCustomerById(customerToDeleteId.toString())
);
}
}

123
application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java

@ -0,0 +1,123 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Dashboard;
import org.thingsboard.client.model.DashboardInfo;
import org.thingsboard.client.model.PageDataDashboardInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class DashboardApiClientTest extends AbstractApiClientTest {
@Test
public void testDashboardLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
// create 20 dashboards
for (int i = 0; i < 20; i++) {
Dashboard dashboard = new Dashboard();
String dashboardTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i;
dashboard.setTitle(dashboardTitle);
client.saveDashboard(dashboard, null);
}
// find all, check count
PageDataDashboardInfo allDashboards = client.getTenantDashboards(100, 0, null, null, null, null);
assertNotNull(allDashboards);
assertNotNull(allDashboards.getData());
int initialSize = allDashboards.getData().size();
assertEquals("Expected 20 dashboards, but got " + initialSize, 20, initialSize);
List<DashboardInfo> createdDashboards = allDashboards.getData();
// find all with search text, check count
PageDataDashboardInfo filteredDashboards = client.getTenantDashboards(100, 0, null, TEST_PREFIX_2, null, null);
assertEquals("Expected exactly 10 dashboards matching prefix", 10, filteredDashboards.getData().size());
// find by id
DashboardInfo searchDashboard = createdDashboards.get(10);
DashboardInfo fetchedDashboard = client.getDashboardInfoById(searchDashboard.getId().getId().toString());
assertEquals(searchDashboard.getTitle(), fetchedDashboard.getTitle());
// update dashboard
Dashboard dashboardToUpdate = new Dashboard();
dashboardToUpdate.setId(fetchedDashboard.getId());
dashboardToUpdate.setTitle(fetchedDashboard.getTitle() + "_updated");
dashboardToUpdate.setVersion(fetchedDashboard.getVersion());
client.saveDashboard(dashboardToUpdate, null);
DashboardInfo updatedDashboard = client.getDashboardInfoById(fetchedDashboard.getId().getId().toString());
assertEquals(fetchedDashboard.getTitle() + "_updated", updatedDashboard.getTitle());
// assign dashboard to customer and verify
String customerId = savedClientCustomer.getId().getId().toString();
String dashboardId = createdDashboards.get(0).getId().getId().toString();
client.assignDashboardToCustomer(customerId, dashboardId);
PageDataDashboardInfo customerDashboards = client.getCustomerDashboards(customerId, 100, 0, null, null, null, null);
assertEquals(1, customerDashboards.getData().size());
assertEquals(createdDashboards.get(0).getTitle(), customerDashboards.getData().get(0).getTitle());
// unassign dashboard from customer
client.unassignDashboardFromCustomer(customerId, dashboardId);
PageDataDashboardInfo dashboardsAfterUnassign = client.getCustomerDashboards(customerId, 100, 0, null, null, null, null);
assertEquals(0, dashboardsAfterUnassign.getData().size());
// make dashboard public and verify
client.assignDashboardToPublicCustomer(dashboardId);
DashboardInfo publicDashboard = client.getDashboardInfoById(dashboardId);
assertNotNull(publicDashboard.getAssignedCustomers());
assertTrue(publicDashboard.getAssignedCustomers().size() > 0);
// remove public access
client.unassignDashboardFromPublicCustomer(dashboardId);
// delete dashboard
UUID dashboardToDeleteId = createdDashboards.get(0).getId().getId();
client.deleteDashboard(dashboardToDeleteId.toString());
// verify deletion
PageDataDashboardInfo dashboardsAfterDelete = client.getTenantDashboards(100, 0, null, null, null, null);
assertEquals(initialSize - 1, dashboardsAfterDelete.getData().size());
assertReturns404(() ->
client.getDashboardInfoById(dashboardToDeleteId.toString())
);
}
@Test
public void testGetServerTime() throws Exception {
Long serverTime = client.getServerTime();
assertNotNull(serverTime);
}
@Test
public void testGetMaxDatapointsLimit() throws Exception {
Long maxDatapointsLimit = client.getMaxDatapointsLimit();
assertNotNull(maxDatapointsLimit);
}
}

114
application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java

@ -0,0 +1,114 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.DeviceCredentials;
import org.thingsboard.client.model.DeviceCredentialsType;
import org.thingsboard.client.model.PageDataDevice;
import org.thingsboard.client.model.SaveDeviceWithCredentialsRequest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class DeviceApiClientTest extends AbstractApiClientTest {
@Test
public void testDeviceLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<Device> createdDevices = new ArrayList<>();
// create 20 devices
for (int i = 0; i < 20; i++) {
Device device = new Device();
String deviceName = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i;
device.setName(deviceName);
device.setLabel("Test Device " + i);
device.setType(((i % 2 == 0) ? "default" : "thermostat"));
Device createdDevice = client.saveDevice(device, null, null, null, null);
assertNotNull(createdDevice);
assertNotNull(createdDevice.getId());
assertEquals(deviceName, createdDevice.getName());
createdDevices.add(createdDevice);
}
// find all, check count
PageDataDevice allDevices = client.getTenantDevices(100, 0, null, null, null, null);
assertNotNull(allDevices);
assertNotNull(allDevices.getData());
int initialSize = allDevices.getData().size();
assertEquals("Expected at least 20 devices, but got " + allDevices.getData().size(), 20, initialSize);
//find all with search text, check count
PageDataDevice allDevicesBySearchText = client.getTenantDevices(10, 0, null, TEST_PREFIX_2, null, null);
assertEquals("Expected exactly 10 test devices", 10, allDevicesBySearchText.getData().size());
// find by id
Device searchDevice = createdDevices.get(10);
Device device = client.getDeviceById(searchDevice.getId().getId().toString());
assertEquals(searchDevice.getName(), device.getName());
// create device with credentials
Device deviceWithCreds = new Device();
deviceWithCreds.setName("device-with-creds");
DeviceCredentials creds = new DeviceCredentials();
creds.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN);
creds.setCredentialsId("TEST_ACCESS_TOKEN");
SaveDeviceWithCredentialsRequest request = new SaveDeviceWithCredentialsRequest();
request.setDevice(deviceWithCreds);
request.setCredentials(creds);
Device savedDeviceWithCreds = client.saveDeviceWithCredentials(request, null, null, null);
assertEquals("device-with-creds", savedDeviceWithCreds.getName());
// find credentials by device id
DeviceCredentials fetchedCreds = client.getDeviceCredentialsByDeviceId(savedDeviceWithCreds.getId().getId().toString());
assertEquals(creds.getCredentialsId(), fetchedCreds.getCredentialsId());
// delete device
UUID deviceToDeleteId = createdDevices.get(0).getId().getId();
client.deleteDevice(deviceToDeleteId.toString());
// Verify the device is deleted
PageDataDevice devicesAfterDelete = client.getTenantDevices(100, 0, null, null, null, null);
assertEquals(initialSize, devicesAfterDelete.getData().size());
assertReturns404(() ->
client.getDeviceById(deviceToDeleteId.toString()));
// assign device to customer
client.assignDeviceToCustomer(savedClientCustomer.getId().getId().toString(), savedDeviceWithCreds.getId().getId().toString());
// check customer devices
PageDataDevice pageDataDevice = client.getCustomerDevices(savedClientCustomer.getId().getId().toString(), 100, 0, null, null, null, null);
List<Device> data = pageDataDevice.getData();
assertEquals(1, data.size());
assertEquals(savedDeviceWithCreds.getName(), data.get(0).getName());
}
}

53
application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java

@ -0,0 +1,53 @@
/**
* Copyright © 2016-2026 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.client;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.thingsboard.client.model.Device;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
@DaoSqlTest
public class DeviceConnectivityApiClientTest extends AbstractApiClientTest {
@Test
public void testGetDevicePublishTelemetryCommands() throws Exception {
Device device = new Device();
device.setName(TEST_PREFIX + System.currentTimeMillis());
device.setType("default");
Device savedDevice = client.saveDevice(device, null, null, null, null);
String token = client.getDeviceCredentialsByDeviceId(savedDevice.getId().getId().toString()).getCredentialsId();
String deviceId = savedDevice.getId().getId().toString();
JsonNode commands = client.getDevicePublishTelemetryCommands(deviceId);
assertEquals("curl -v -X POST http://localhost:8080/api/v1/" + token + "/telemetry --header Content-Type:application/json --data \"{temperature:25}\"", commands.get("http").get("http").asText());
assertEquals("mosquitto_pub -d -q 1 -h localhost -p 1883 -t v1/devices/me/telemetry -u \"" + token + "\" -m \"{temperature:25}\"", commands.get("mqtt").get("mqtt").asText());
assertEquals("coap-client -v 6 -m POST -t \"application/json\" -e \"{temperature:25}\" coap://localhost:5683/api/v1/" + token + "/telemetry", commands.get("coap").get("coap").asText());
}
@Test
public void testGetDevicePublishTelemetryCommands_nonExistentDevice() {
String nonExistentId = UUID.randomUUID().toString();
assertReturns404(() -> client.getDevicePublishTelemetryCommands(nonExistentId));
}
}

157
application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java

@ -0,0 +1,157 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.DefaultDeviceProfileConfiguration;
import org.thingsboard.client.model.DefaultDeviceProfileTransportConfiguration;
import org.thingsboard.client.model.DeviceProfile;
import org.thingsboard.client.model.DeviceProfileData;
import org.thingsboard.client.model.DeviceProfileInfo;
import org.thingsboard.client.model.DeviceProfileType;
import org.thingsboard.client.model.DeviceTransportType;
import org.thingsboard.client.model.EntityInfo;
import org.thingsboard.client.model.PageDataDeviceProfile;
import org.thingsboard.client.model.PageDataDeviceProfileInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class DeviceProfileApiClientTest extends AbstractApiClientTest {
@Test
public void testDeviceProfileLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<DeviceProfile> createdProfiles = new ArrayList<>();
// Get initial count (there should be a default profile)
PageDataDeviceProfile initialProfiles = client.getDeviceProfiles(100, 0, null, null, null);
assertNotNull(initialProfiles);
int initialSize = initialProfiles.getData().size();
assertTrue("Expected at least 1 default device profile", initialSize >= 1);
// Get default device profile info
DeviceProfileInfo defaultProfileInfo = client.getDefaultDeviceProfileInfo();
assertNotNull(defaultProfileInfo);
assertNotNull(defaultProfileInfo.getName());
// Create multiple device profiles
for (int i = 0; i < 5; i++) {
DeviceProfile deviceProfile = new DeviceProfile();
deviceProfile.setName("Test Device Profile " + timestamp + "_" + i);
deviceProfile.setDescription("Test description " + i);
deviceProfile.setType(DeviceProfileType.DEFAULT);
deviceProfile.setTransportType(DeviceTransportType.DEFAULT);
DeviceProfileData deviceProfileData = new DeviceProfileData();
DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration();
configuration.setType(DeviceProfileType.DEFAULT.getValue());
deviceProfileData.setConfiguration(configuration);
DefaultDeviceProfileTransportConfiguration transportConf = new DefaultDeviceProfileTransportConfiguration();
transportConf.setType(DeviceTransportType.DEFAULT.getValue());
deviceProfileData.setTransportConfiguration(transportConf);
deviceProfile.setProfileData(deviceProfileData);
deviceProfile.setDefault(false);
deviceProfile.setDefaultRuleChainId(null);
DeviceProfile created = client.saveDeviceProfile(deviceProfile);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(deviceProfile.getName(), created.getName());
assertEquals(deviceProfile.getDescription(), created.getDescription());
assertEquals(DeviceProfileType.DEFAULT, created.getType());
assertEquals(DeviceTransportType.DEFAULT, created.getTransportType());
assertFalse(created.getDefault());
createdProfiles.add(created);
}
// Find all, check count
PageDataDeviceProfile allProfiles = client.getDeviceProfiles(100, 0, null, null, null);
assertNotNull(allProfiles);
assertEquals(initialSize + 5, allProfiles.getData().size());
// Find all with text search
PageDataDeviceProfile filteredProfiles = client.getDeviceProfiles(100, 0, "Test Device Profile " + timestamp, null, null);
assertEquals(5, filteredProfiles.getData().size());
// Get by id
DeviceProfile searchProfile = createdProfiles.get(2);
DeviceProfile fetchedProfile = client.getDeviceProfileById(searchProfile.getId().getId().toString(), false);
assertEquals(searchProfile.getName(), fetchedProfile.getName());
assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription());
// Update device profile
fetchedProfile.setDescription("Updated description");
DeviceProfile updatedProfile = client.saveDeviceProfile(fetchedProfile);
assertEquals("Updated description", updatedProfile.getDescription());
assertEquals(fetchedProfile.getName(), updatedProfile.getName());
// Get device profile info by id
DeviceProfileInfo profileInfo = client.getDefaultDeviceProfileInfo();
assertNotNull(profileInfo);
assertEquals(searchProfile.getType().getValue().toLowerCase(), profileInfo.getName());
assertEquals(DeviceTransportType.DEFAULT, profileInfo.getTransportType());
// Get device profile infos (paginated)
PageDataDeviceProfileInfo profileInfos = client.getDeviceProfileInfos(100, 0, null, null, null, null);
assertNotNull(profileInfos);
assertEquals(initialSize + 5, profileInfos.getData().size());
// Set a profile as default
DeviceProfile profileToSetDefault = createdProfiles.get(1);
DeviceProfile newDefault = client.setDefaultDeviceProfile(profileToSetDefault.getId().getId().toString());
assertNotNull(newDefault);
assertTrue(newDefault.getDefault());
// Verify default profile info now points to the new default
DeviceProfileInfo newDefaultInfo = client.getDefaultDeviceProfileInfo();
assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName());
// Get device profile names
List<EntityInfo> profileNames = client.getDeviceProfileNames(false);
assertNotNull(profileNames);
assertEquals(createdProfiles.size() + 1, profileNames.size());
// Delete device profile (cannot delete the default one, so delete a non-default one)
UUID profileToDeleteId = createdProfiles.get(0).getId().getId();
client.deleteDeviceProfile(profileToDeleteId.toString());
// Verify the profile is deleted
assertReturns404(() ->
client.getDeviceProfileById(profileToDeleteId.toString(), false));
// Verify count after deletion
PageDataDeviceProfile profilesAfterDelete = client.getDeviceProfiles(100, 0, null, null, null);
assertEquals(initialSize + 4, profilesAfterDelete.getData().size());
// Restore original default profile
DeviceProfile originalDefault = initialProfiles.getData().stream()
.filter(DeviceProfile::getDefault)
.findFirst()
.orElseThrow();
client.setDefaultDeviceProfile(originalDefault.getId().getId().toString());
}
}

105
application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java

@ -0,0 +1,105 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.After;
import org.junit.Test;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.model.Domain;
import org.thingsboard.client.model.DomainInfo;
import org.thingsboard.client.model.PageDataDomainInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class DomainApiClientTest extends AbstractApiClientTest {
List<Domain> createdDomains = new ArrayList<>();
@After
public void afterDomainTest() {
createdDomains.forEach(domain -> {
try {
client.deleteDomain(domain.getId().getId());
} catch (ApiException e) {
// ignore
}
});
}
@Test
public void testDomainLifecycle() throws Exception {
client.login("sysadmin@thingsboard.org", "sysadmin");
long timestamp = System.currentTimeMillis();
// create 5 domains
for (int i = 0; i < 5; i++) {
Domain domain = new Domain();
domain.setName("domain." + i + ".com");
domain.setOauth2Enabled(false);
domain.setPropagateToEdge(false);
Domain created = client.saveDomain(domain, null);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(domain.getName(), created.getName());
assertEquals(false, created.getOauth2Enabled());
createdDomains.add(created);
}
// list tenant domains with text search
PageDataDomainInfo filteredDomains = client.getTenantDomainInfos(100, 0,
"domain.", null, null);
assertNotNull(filteredDomains);
assertEquals(5, filteredDomains.getData().size());
// get domain info by id
Domain searchDomain = createdDomains.get(2);
DomainInfo fetchedInfo = client.getDomainInfoById(searchDomain.getId().getId());
assertEquals(searchDomain.getName(), fetchedInfo.getName());
assertEquals(searchDomain.getOauth2Enabled(), fetchedInfo.getOauth2Enabled());
assertNotNull(fetchedInfo.getOauth2ClientInfos());
// update domain
Domain domainToUpdate = createdDomains.get(3);
domainToUpdate.setPropagateToEdge(true);
Domain updatedDomain = client.saveDomain(domainToUpdate, null);
assertEquals(true, updatedDomain.getPropagateToEdge());
// delete domain
UUID domainToDeleteId = createdDomains.get(0).getId().getId();
createdDomains.remove(0);
client.deleteDomain(domainToDeleteId);
// verify deletion
assertReturns404(() ->
client.getDomainInfoById(domainToDeleteId)
);
PageDataDomainInfo domainsAfterDelete = client.getTenantDomainInfos(100, 0,
"domain.", null, null);
assertEquals(4, domainsAfterDelete.getData().size());
}
}

141
application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java

@ -0,0 +1,141 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Edge;
import org.thingsboard.client.model.EdgeInfo;
import org.thingsboard.client.model.PageDataEdge;
import org.thingsboard.client.model.PageDataEdgeInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class EdgeApiClientTest extends AbstractApiClientTest {
@Test
public void testEdgeLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<Edge> createdEdges = new ArrayList<>();
// create 5 edges
for (int i = 0; i < 5; i++) {
Edge edge = new Edge();
edge.setName(TEST_PREFIX + "Edge_" + timestamp + "_" + i);
edge.setType("gateway");
edge.setLabel("Test Edge " + i);
edge.setRoutingKey("routing_key_" + timestamp + "_" + i);
edge.setSecret("secret_key_" + timestamp + "_" + i);
Edge created = client.saveEdge(edge);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(edge.getName(), created.getName());
assertEquals("gateway", created.getType());
assertNotNull(created.getRoutingKey());
assertNotNull(created.getSecret());
createdEdges.add(created);
}
// list tenant edges with text search
PageDataEdge filteredEdges = client.getTenantEdges(100, 0, null,
TEST_PREFIX + "Edge_" + timestamp, null, null);
assertNotNull(filteredEdges);
assertEquals(5, filteredEdges.getData().size());
// list tenant edges with type filter
PageDataEdge typedEdges = client.getTenantEdges(100, 0, "gateway",
TEST_PREFIX + "Edge_" + timestamp, null, null);
assertEquals(5, typedEdges.getData().size());
// get tenant edge infos
PageDataEdgeInfo edgeInfos = client.getTenantEdgeInfos(100, 0, null,
TEST_PREFIX + "Edge_" + timestamp, null, null);
assertEquals(5, edgeInfos.getData().size());
// get edge by id
Edge searchEdge = createdEdges.get(2);
Edge fetchedEdge = client.getEdgeById(searchEdge.getId().getId().toString());
assertEquals(searchEdge.getName(), fetchedEdge.getName());
assertEquals(searchEdge.getType(), fetchedEdge.getType());
assertEquals(searchEdge.getRoutingKey(), fetchedEdge.getRoutingKey());
// get edge by name
Edge fetchedByName = client.getTenantEdgeByName(searchEdge.getName());
assertEquals(searchEdge.getId().getId(), fetchedByName.getId().getId());
// get edges by list of ids
List<String> idsToFetch = List.of(
createdEdges.get(0).getId().getId().toString(),
createdEdges.get(1).getId().getId().toString()
);
List<Edge> edgeList = client.getEdgeList(idsToFetch);
assertEquals(2, edgeList.size());
// update edge
Edge edgeToUpdate = createdEdges.get(3);
edgeToUpdate.setLabel("Updated Label");
Edge updatedEdge = client.saveEdge(edgeToUpdate);
assertEquals("Updated Label", updatedEdge.getLabel());
// assign edge to customer
String customerId = savedClientCustomer.getId().getId().toString();
String edgeId = createdEdges.get(1).getId().getId().toString();
Edge assignedEdge = client.assignEdgeToCustomer(customerId, edgeId);
assertNotNull(assignedEdge.getCustomerId());
// get customer edges
PageDataEdge customerEdges = client.getCustomerEdges(customerId, 100, 0,
null, TEST_PREFIX + "Edge_" + timestamp, null, null);
assertEquals(1, customerEdges.getData().size());
// get customer edge infos
PageDataEdgeInfo customerEdgeInfos = client.getCustomerEdgeInfos(customerId, 100, 0,
null, TEST_PREFIX + "Edge_" + timestamp, null, null);
assertEquals(1, customerEdgeInfos.getData().size());
EdgeInfo edgeInfo = customerEdgeInfos.getData().get(0);
assertNotNull(edgeInfo.getCustomerTitle());
// unassign edge from customer
Edge unassignedEdge = client.unassignEdgeFromCustomer(edgeId);
assertNotNull(unassignedEdge);
PageDataEdge customerEdgesAfter = client.getCustomerEdges(customerId, 100, 0,
null, TEST_PREFIX + "Edge_" + timestamp, null, null);
assertEquals(0, customerEdgesAfter.getData().size());
// delete edge
UUID edgeToDeleteId = createdEdges.get(0).getId().getId();
client.deleteEdge(edgeToDeleteId.toString());
// verify deletion
assertReturns404(() ->
client.getEdgeById(edgeToDeleteId.toString())
);
PageDataEdge edgesAfterDelete = client.getTenantEdges(100, 0, null,
TEST_PREFIX + "Edge_" + timestamp, null, null);
assertEquals(4, edgesAfterDelete.getData().size());
}
}

289
application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java

@ -0,0 +1,289 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AliasEntityId;
import org.thingsboard.client.model.Asset;
import org.thingsboard.client.model.AssetTypeFilter;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.DeviceTypeFilter;
import org.thingsboard.client.model.Direction;
import org.thingsboard.client.model.EntityData;
import org.thingsboard.client.model.EntityDataPageLink;
import org.thingsboard.client.model.EntityDataQuery;
import org.thingsboard.client.model.EntityDataSortOrder;
import org.thingsboard.client.model.EntityKey;
import org.thingsboard.client.model.EntityKeyType;
import org.thingsboard.client.model.EntityKeyValueType;
import org.thingsboard.client.model.EntityListFilter;
import org.thingsboard.client.model.EntityNameFilter;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.FilterPredicateValueString;
import org.thingsboard.client.model.KeyFilter;
import org.thingsboard.client.model.PageDataEntityData;
import org.thingsboard.client.model.SingleEntityFilter;
import org.thingsboard.client.model.StringFilterPredicate;
import org.thingsboard.client.model.StringOperation;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class EntityQueryApiClientTest extends AbstractApiClientTest {
private static final String QUERY_TEST_PREFIX = "QueryTest_";
private EntityDataPageLink pageLink(int pageSize) {
return new EntityDataPageLink()
.pageSize(pageSize)
.page(0)
.sortOrder(new EntityDataSortOrder()
.key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"))
.direction(Direction.ASC));
}
@Test
public void testFindByDeviceTypeFilter() throws Exception {
long ts = System.currentTimeMillis();
String type1 = "temperatureSensor";
String type2 = "humiditySensor";
for (int i = 0; i < 3; i++) {
Device d = new Device();
d.setName(QUERY_TEST_PREFIX + "temp_" + ts + "_" + i);
d.setType(type1);
client.saveDevice(d, null, null, null, null);
}
for (int i = 0; i < 2; i++) {
Device d = new Device();
d.setName(QUERY_TEST_PREFIX + "hum_" + ts + "_" + i);
d.setType(type2);
client.saveDevice(d, null, null, null, null);
}
// filter by single device type
EntityDataQuery singleTypeQuery = new EntityDataQuery()
.entityFilter(new DeviceTypeFilter()
.deviceTypes(List.of(type1)))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData result = client.findEntityDataByQuery(singleTypeQuery);
assertNotNull(result);
assertEquals(3, result.getTotalElements().intValue());
for (EntityData entity : result.getData()) {
assertNotNull(entity.getEntityId());
}
// filter by multiple device types
EntityDataQuery multiTypeQuery = new EntityDataQuery()
.entityFilter(new DeviceTypeFilter()
.deviceTypes(List.of(type1, type2)))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData multiResult = client.findEntityDataByQuery(multiTypeQuery);
assertNotNull(multiResult);
assertEquals(5, multiResult.getTotalElements().intValue());
// filter by device type + name filter
EntityDataQuery nameFilterQuery = new EntityDataQuery()
.entityFilter(new DeviceTypeFilter()
.deviceTypes(List.of(type1, type2))
.deviceNameFilter(QUERY_TEST_PREFIX + "temp_" + ts))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData nameResult = client.findEntityDataByQuery(nameFilterQuery);
assertNotNull(nameResult);
assertEquals(3, nameResult.getTotalElements().intValue());
}
@Test
public void testFindByEntityNameFilter() throws Exception {
long ts = System.currentTimeMillis();
String prefix = QUERY_TEST_PREFIX + "named_" + ts;
for (int i = 0; i < 4; i++) {
Device d = new Device();
d.setName(prefix + "_" + i);
d.setType("default");
client.saveDevice(d, null, null, null, null);
}
EntityDataQuery query = new EntityDataQuery()
.entityFilter(new EntityNameFilter()
.entityType(EntityType.DEVICE)
.entityNameFilter(prefix))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData result = client.findEntityDataByQuery(query);
assertNotNull(result);
assertEquals(4, result.getTotalElements().intValue());
assertFalse(result.getHasNext());
}
@Test
public void testFindByEntityListFilter() throws Exception {
long ts = System.currentTimeMillis();
Device d1 = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_1").type("default"), null, null, null, null);
Device d2 = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_2").type("default"), null, null, null, null);
client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_3").type("default"), null, null, null, null);
EntityDataQuery query = new EntityDataQuery()
.entityFilter(new EntityListFilter()
.entityType(EntityType.DEVICE)
.entityList(List.of(
d1.getId().getId().toString(),
d2.getId().getId().toString())))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData result = client.findEntityDataByQuery(query);
assertNotNull(result);
assertEquals(2, result.getTotalElements().intValue());
List<String> returnedIds = result.getData().stream()
.map(e -> e.getEntityId().getId().toString())
.collect(Collectors.toList());
assertTrue(returnedIds.contains(d1.getId().getId().toString()));
assertTrue(returnedIds.contains(d2.getId().getId().toString()));
}
@Test
public void testFindBySingleEntityFilter() throws Exception {
long ts = System.currentTimeMillis();
Device device = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "single_" + ts).type("default"), null, null, null, null);
EntityDataQuery query = new EntityDataQuery()
.entityFilter(new SingleEntityFilter()
.singleEntity(new AliasEntityId()
.id(device.getId().getId())
.entityType(EntityType.DEVICE)))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData result = client.findEntityDataByQuery(query);
assertNotNull(result);
assertEquals(1, result.getTotalElements().intValue());
assertEquals(device.getId().getId().toString(),
result.getData().get(0).getEntityId().getId().toString());
}
@Test
public void testFindByAssetTypeFilter() throws Exception {
long ts = System.currentTimeMillis();
String assetType = "building";
for (int i = 0; i < 3; i++) {
Asset a = new Asset();
a.setName(QUERY_TEST_PREFIX + "asset_" + ts + "_" + i);
a.setType(assetType);
client.saveAsset(a, null, null, null);
}
EntityDataQuery query = new EntityDataQuery()
.entityFilter(new AssetTypeFilter()
.assetTypes(List.of(assetType)))
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData result = client.findEntityDataByQuery(query);
assertNotNull(result);
assertEquals(3, result.getTotalElements().intValue());
}
@Test
public void testFindWithKeyFilter() throws Exception {
long ts = System.currentTimeMillis();
String matchName = QUERY_TEST_PREFIX + "kf_match_" + ts;
String noMatchName = QUERY_TEST_PREFIX + "kf_other_" + ts;
client.saveDevice(new Device().name(matchName).type("default"), null, null, null, null);
client.saveDevice(new Device().name(noMatchName).type("default"), null, null, null, null);
KeyFilter nameKeyFilter = new KeyFilter()
.key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"))
.valueType(EntityKeyValueType.STRING)
.predicate(new StringFilterPredicate()
.operation(StringOperation.CONTAINS)
.value(new FilterPredicateValueString().defaultValue("kf_match"))
.ignoreCase(true));
EntityDataQuery query = new EntityDataQuery()
.entityFilter(new EntityNameFilter()
.entityType(EntityType.DEVICE)
.entityNameFilter(QUERY_TEST_PREFIX + "kf_"))
.addKeyFiltersItem(nameKeyFilter)
.pageLink(pageLink(10))
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
PageDataEntityData result = client.findEntityDataByQuery(query);
assertNotNull(result);
assertEquals(1, result.getTotalElements().intValue());
}
@Test
public void testFindWithPagination() throws Exception {
long ts = System.currentTimeMillis();
for (int i = 0; i < 5; i++) {
Device d = new Device();
d.setName(QUERY_TEST_PREFIX + "page_" + ts + "_" + i);
d.setType("default");
client.saveDevice(d, null, null, null, null);
}
EntityDataPageLink smallPage = new EntityDataPageLink()
.pageSize(2)
.page(0)
.sortOrder(new EntityDataSortOrder()
.key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"))
.direction(Direction.ASC));
EntityDataQuery query = new EntityDataQuery()
.entityFilter(new EntityNameFilter()
.entityType(EntityType.DEVICE)
.entityNameFilter(QUERY_TEST_PREFIX + "page_" + ts))
.pageLink(smallPage)
.addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name"));
// first page
PageDataEntityData page1 = client.findEntityDataByQuery(query);
assertNotNull(page1);
assertEquals(5, page1.getTotalElements().intValue());
assertEquals(3, page1.getTotalPages().intValue());
assertEquals(2, page1.getData().size());
assertTrue(page1.getHasNext());
// last page
smallPage.setPage(2);
PageDataEntityData lastPage = client.findEntityDataByQuery(query);
assertNotNull(lastPage);
assertEquals(1, lastPage.getData().size());
assertFalse(lastPage.getHasNext());
}
}

182
application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java

@ -0,0 +1,182 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Asset;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntityRelation;
import org.thingsboard.client.model.EntityRelationInfo;
import org.thingsboard.client.model.EntityRelationsQuery;
import org.thingsboard.client.model.EntitySearchDirection;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.RelationEntityTypeFilter;
import org.thingsboard.client.model.RelationTypeGroup;
import org.thingsboard.client.model.RelationsSearchParameters;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class EntityRelationApiClientTest extends AbstractApiClientTest {
@Test
public void testEntityRelationLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
// create assets and devices to relate
Asset building = new Asset();
building.setName(TEST_PREFIX + "Building_" + timestamp);
building.setType("building");
building = client.saveAsset(building, null, null, null);
Asset floor = new Asset();
floor.setName(TEST_PREFIX + "Floor_" + timestamp);
floor.setType("floor");
floor = client.saveAsset(floor, null, null, null);
Device device1 = new Device();
device1.setName(TEST_PREFIX + "Sensor_" + timestamp + "_1");
device1.setType("sensor");
device1 = client.saveDevice(device1, null, null, null, null);
Device device2 = new Device();
device2.setName(TEST_PREFIX + "Sensor_" + timestamp + "_2");
device2.setType("sensor");
device2 = client.saveDevice(device2, null, null, null, null);
Device device3 = new Device();
device3.setName(TEST_PREFIX + "Sensor_" + timestamp + "_3");
device3.setType("sensor");
device3 = client.saveDevice(device3, null, null, null, null);
// create relations: building -> Contains -> floor, floor -> Contains -> device1/device2/device3
EntityRelation buildingToFloor = new EntityRelation();
buildingToFloor.setFrom(building.getId());
buildingToFloor.setTo(floor.getId());
buildingToFloor.setType("Contains");
buildingToFloor.setTypeGroup(RelationTypeGroup.COMMON);
EntityRelation savedRelation = client.saveRelation(buildingToFloor);
assertNotNull(savedRelation);
assertEquals("Contains", savedRelation.getType());
client.saveRelation(new EntityRelation()
.from(floor.getId())
.to(device1.getId())
.type("Contains")
.typeGroup(RelationTypeGroup.COMMON));
client.saveRelation(new EntityRelation()
.from(floor.getId())
.to(device2.getId())
.type("Contains").typeGroup(RelationTypeGroup.COMMON));
client.saveRelation(new EntityRelation()
.from(floor.getId())
.to(device3.getId())
.type("Manages")
.typeGroup(RelationTypeGroup.COMMON));
// get specific relation
EntityRelation fetched = client.getRelation(
building.getId().getId().toString(), "ASSET",
"Contains",
floor.getId().getId().toString(), "ASSET",
RelationTypeGroup.COMMON.getValue());
assertNotNull(fetched);
assertEquals("Contains", fetched.getType());
// find all relations from floor
List<EntityRelation> fromFloor = client.findEntityRelationsByFrom("ASSET",
floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue());
assertEquals(3, fromFloor.size());
// find relations from floor with type filter "Contains"
List<EntityRelation> containsFromFloor = client.findEntityRelationsByFromAndRelationType("ASSET",
floor.getId().getId().toString(), "Contains", RelationTypeGroup.COMMON.getValue());
assertEquals(2, containsFromFloor.size());
// find relations to device1
List<EntityRelation> toDevice1 = client.findEntityRelationsByTo("DEVICE",
device1.getId().getId().toString(), RelationTypeGroup.COMMON.getValue());
assertEquals(1, toDevice1.size());
assertEquals("Contains", toDevice1.get(0).getType());
// find relations to device3 with type filter "Manages"
List<EntityRelation> managesToDevice3 = client.findEntityRelationsByToAndRelationType("DEVICE",
device3.getId().getId().toString(), "Manages", RelationTypeGroup.COMMON.getValue());
assertEquals(1, managesToDevice3.size());
// find info by from (includes entity names)
List<EntityRelationInfo> infoFromFloor = client.findEntityRelationInfosByFrom("ASSET",
floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue());
assertEquals(3, infoFromFloor.size());
Device finalDevice = device1;
assertTrue(infoFromFloor.stream().anyMatch(info ->
finalDevice.getName().equals(info.getToName())));
// find info by to
List<EntityRelationInfo> infoToDevice2 = client.findEntityRelationInfosByTo("DEVICE",
device2.getId().getId().toString(), RelationTypeGroup.COMMON.getValue());
assertEquals(1, infoToDevice2.size());
assertEquals(floor.getName(), infoToDevice2.get(0).getFromName());
// find by query - search from building, direction FROM, max 2 levels
RelationsSearchParameters params = new RelationsSearchParameters();
params.setRootId(building.getId().getId());
params.setRootType(EntityType.ASSET);
params.setDirection(EntitySearchDirection.FROM);
params.setRelationTypeGroup(RelationTypeGroup.COMMON);
params.setMaxLevel(2);
RelationEntityTypeFilter filter = new RelationEntityTypeFilter();
filter.setRelationType("Contains");
filter.setEntityTypes(List.of(EntityType.ASSET, EntityType.DEVICE));
EntityRelationsQuery query = new EntityRelationsQuery();
query.setParameters(params);
query.setFilters(List.of(filter));
List<EntityRelation> queryResult = client.findEntityRelationsByQuery(query);
assertTrue(queryResult.size() >= 3);
// find info by query
List<EntityRelationInfo> infoQueryResult = client.findEntityRelationInfosByQuery(query);
assertTrue(infoQueryResult.size() >= 3);
// delete single relation
client.deleteRelation(
floor.getId().getId().toString(), "ASSET",
"Manages",
device3.getId().getId().toString(), "DEVICE",
RelationTypeGroup.COMMON.getValue());
// verify deletion
List<EntityRelation> afterDelete = client.findEntityRelationsByFrom("ASSET",
floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue());
assertEquals(2, afterDelete.size());
// delete all relations for building
client.deleteRelations(building.getId().getId().toString(), "ASSET");
List<EntityRelation> afterDeleteAll = client.findEntityRelationsByFrom("ASSET",
building.getId().getId().toString(), RelationTypeGroup.COMMON.getValue());
assertEquals(0, afterDeleteAll.size());
}
}

267
application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java

@ -0,0 +1,267 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AttributesEntityView;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.EntitySubtype;
import org.thingsboard.client.model.EntityView;
import org.thingsboard.client.model.EntityViewInfo;
import org.thingsboard.client.model.PageDataEntityView;
import org.thingsboard.client.model.PageDataEntityViewInfo;
import org.thingsboard.client.model.TelemetryEntityView;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class EntityViewApiClientTest extends AbstractApiClientTest {
private static final String EV_PREFIX = "EvTest_";
@Test
public void testSaveAndGetEntityView() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
EntityView ev = new EntityView();
ev.setName(EV_PREFIX + "save_" + ts);
ev.setType("testType");
ev.setEntityId(device.getId());
ev.setKeys(new TelemetryEntityView()
.timeseries(List.of("temperature", "humidity"))
.attributes(new AttributesEntityView()
.cs(List.of("firmware"))
.ss(List.of("active"))
.sh(List.of())));
ev.setStartTimeMs(1000L);
ev.setEndTimeMs(2000L);
EntityView saved = client.saveEntityView(ev, null, null, null);
assertNotNull(saved);
assertNotNull(saved.getId());
assertEquals(ev.getName(), saved.getName());
assertEquals("testType", saved.getType());
assertEquals(device.getId().getId(), saved.getEntityId().getId());
assertEquals(List.of("temperature", "humidity"), saved.getKeys().getTimeseries());
assertEquals(1000L, saved.getStartTimeMs().longValue());
assertEquals(2000L, saved.getEndTimeMs().longValue());
// get by id
String evId = saved.getId().getId().toString();
EntityView fetched = client.getEntityViewById(evId);
assertNotNull(fetched);
assertEquals(saved.getName(), fetched.getName());
assertEquals(saved.getType(), fetched.getType());
assertEquals(saved.getEntityId().getId(), fetched.getEntityId().getId());
}
@Test
public void testGetEntityViewInfoById() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
EntityView saved = createEntityView(EV_PREFIX + "info_" + ts, "infoType", device);
EntityViewInfo info = client.getEntityViewInfoById(saved.getId().getId().toString());
assertNotNull(info);
assertEquals(saved.getName(), info.getName());
assertEquals("infoType", info.getType());
assertNotNull(info.getEntityId());
}
@Test
public void testUpdateEntityView() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
EntityView saved = createEntityView(EV_PREFIX + "update_" + ts, "default", device);
saved.setName(EV_PREFIX + "updated_" + ts);
saved.setKeys(new TelemetryEntityView()
.timeseries(List.of("temperature", "pressure"))
.attributes(new AttributesEntityView()
.cs(List.of())
.ss(List.of())
.sh(List.of())));
EntityView updated = client.saveEntityView(saved, null, null, null);
assertEquals(EV_PREFIX + "updated_" + ts, updated.getName());
assertEquals(List.of("temperature", "pressure"), updated.getKeys().getTimeseries());
assertEquals(saved.getId().getId(), updated.getId().getId());
}
@Test
public void testDeleteEntityView() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
EntityView saved = createEntityView(EV_PREFIX + "delete_" + ts, "default", device);
String evId = saved.getId().getId().toString();
client.getEntityViewById(evId);
client.deleteEntityView(evId);
assertReturns404(() -> client.getEntityViewById(evId));
}
@Test
public void testGetTenantEntityViews() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
for (int i = 0; i < 3; i++) {
createEntityView(EV_PREFIX + "tenant_" + ts + "_" + i, "tenantViewType", device);
}
PageDataEntityView page = client.getTenantEntityViews(100, 0, null, EV_PREFIX + "tenant_" + ts, null, null);
assertNotNull(page);
assertEquals(3, page.getTotalElements().intValue());
for (EntityView ev : page.getData()) {
assertTrue(ev.getName().startsWith(EV_PREFIX + "tenant_" + ts));
}
}
@Test
public void testGetTenantEntityViewInfos() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
createEntityView(EV_PREFIX + "tinfo_" + ts, "default", device);
PageDataEntityViewInfo page = client.getTenantEntityViewInfos(100, 0, null, EV_PREFIX + "tinfo_" + ts, null, null);
assertNotNull(page);
assertEquals(1, page.getTotalElements().intValue());
assertEquals(EV_PREFIX + "tinfo_" + ts, page.getData().get(0).getName());
}
@Test
public void testAssignAndUnassignEntityViewToCustomer() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
EntityView saved = createEntityView(EV_PREFIX + "assign_" + ts, "default", device);
String evId = saved.getId().getId().toString();
String customerId = savedClientCustomer.getId().getId().toString();
// assign to customer
EntityView assigned = client.assignEntityViewToCustomer(customerId, evId);
assertNotNull(assigned);
assertEquals(savedClientCustomer.getId().getId(), assigned.getCustomerId().getId());
// verify in customer entity views
PageDataEntityView customerViews = client.getCustomerEntityViews(
customerId, 100, 0, null, EV_PREFIX + "assign_" + ts, null, null);
assertEquals(1, customerViews.getTotalElements().intValue());
assertEquals(saved.getName(), customerViews.getData().get(0).getName());
// unassign from customer
EntityView unassigned = client.unassignEntityViewFromCustomer(evId);
assertNotNull(unassigned);
PageDataEntityView afterUnassign = client.getCustomerEntityViews(
customerId, 100, 0, null, EV_PREFIX + "assign_" + ts, null, null);
assertEquals(0, afterUnassign.getTotalElements().intValue());
}
@Test
public void testGetCustomerEntityViewInfos() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
EntityView saved = createEntityView(EV_PREFIX + "cinfo_" + ts, "default", device);
String evId = saved.getId().getId().toString();
String customerId = savedClientCustomer.getId().getId().toString();
client.assignEntityViewToCustomer(customerId, evId);
PageDataEntityViewInfo infos = client.getCustomerEntityViewInfos(
customerId, 100, 0, null, EV_PREFIX + "cinfo_" + ts, null, null);
assertNotNull(infos);
assertEquals(1, infos.getTotalElements().intValue());
assertEquals(saved.getName(), infos.getData().get(0).getName());
}
@Test
public void testGetEntityViewTypes() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
createEntityView(EV_PREFIX + "types_" + ts, "uniqueEvType_" + ts, device);
List<EntitySubtype> types = client.getEntityViewTypes();
assertNotNull(types);
assertFalse(types.isEmpty());
List<String> typeNames = types.stream()
.map(EntitySubtype::getType)
.collect(Collectors.toList());
assertTrue(typeNames.contains("uniqueEvType_" + ts));
}
@Test
public void testGetEntityViewById_notFound() {
String nonExistentId = UUID.randomUUID().toString();
assertReturns404(() -> client.getEntityViewById(nonExistentId));
}
@Test
public void testGetTenantEntityViewsPagination() throws Exception {
long ts = System.currentTimeMillis();
Device device = createTestDevice(String.valueOf(ts));
for (int i = 0; i < 5; i++) {
createEntityView(EV_PREFIX + "paged_" + ts + "_" + i, "default", device);
}
PageDataEntityView page1 = client.getTenantEntityViews(2, 0, null, EV_PREFIX + "paged_" + ts, null, null);
assertNotNull(page1);
assertEquals(5, page1.getTotalElements().intValue());
assertEquals(3, page1.getTotalPages().intValue());
assertEquals(2, page1.getData().size());
assertTrue(page1.getHasNext());
PageDataEntityView lastPage = client.getTenantEntityViews(2, 2, null, EV_PREFIX + "paged_" + ts, null, null);
assertEquals(1, lastPage.getData().size());
assertFalse(lastPage.getHasNext());
}
private Device createTestDevice(String suffix) throws Exception {
Device device = new Device();
device.setName(EV_PREFIX + "device_" + suffix);
device.setType("default");
return client.saveDevice(device, null, null, null, null);
}
private EntityView createEntityView(String name, String type, Device device) throws Exception {
EntityView ev = new EntityView();
ev.setName(name);
ev.setType(type);
ev.setEntityId(device.getId());
ev.setKeys(new TelemetryEntityView()
.timeseries(List.of("temperature"))
.attributes(new AttributesEntityView()
.cs(List.of())
.ss(List.of())
.sh(List.of())));
return client.saveEntityView(ev, null, null, null);
}
}

156
application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java

@ -0,0 +1,156 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.MobileApp;
import org.thingsboard.client.model.MobileAppBundle;
import org.thingsboard.client.model.MobileAppBundleInfo;
import org.thingsboard.client.model.MobileAppStatus;
import org.thingsboard.client.model.PageDataMobileApp;
import org.thingsboard.client.model.PageDataMobileAppBundleInfo;
import org.thingsboard.client.model.PlatformType;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class MobileAppApiClientTest extends AbstractApiClientTest {
@Test
public void testMobileAppLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<MobileApp> createdApps = new ArrayList<>();
// create 3 Android apps
for (int i = 0; i < 3; i++) {
MobileApp app = new MobileApp();
app.setPkgName("com.test.android." + timestamp + "." + i);
app.setTitle(TEST_PREFIX + "AndroidApp_" + timestamp + "_" + i);
app.setAppSecret("secret_android_" + timestamp + "_" + i);
app.setPlatformType(PlatformType.ANDROID);
app.setStatus(MobileAppStatus.DRAFT);
MobileApp created = client.saveMobileApp(app);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(app.getPkgName(), created.getPkgName());
assertEquals(PlatformType.ANDROID, created.getPlatformType());
assertEquals(MobileAppStatus.DRAFT, created.getStatus());
createdApps.add(created);
}
// create 2 iOS apps
for (int i = 0; i < 2; i++) {
MobileApp app = new MobileApp();
app.setPkgName("com.test.ios." + timestamp + "." + i);
app.setTitle(TEST_PREFIX + "IosApp_" + timestamp + "_" + i);
app.setAppSecret("secret_ios_" + timestamp + "_" + i);
app.setPlatformType(PlatformType.IOS);
app.setStatus(MobileAppStatus.DRAFT);
MobileApp created = client.saveMobileApp(app);
assertNotNull(created);
createdApps.add(created);
}
// list all tenant mobile apps
PageDataMobileApp allApps = client.getTenantMobileApps(100, 0, null,
null, null, null);
assertNotNull(allApps);
assertEquals(5, allApps.getData().size());
// list with platform type filter
PageDataMobileApp androidApps = client.getTenantMobileApps(100, 0, PlatformType.ANDROID,
null, null, null);
assertEquals(3, androidApps.getData().size());
PageDataMobileApp iosApps = client.getTenantMobileApps(100, 0, PlatformType.IOS,
null, null, null);
assertEquals(2, iosApps.getData().size());
// get mobile app by id
MobileApp searchApp = createdApps.get(1);
MobileApp fetchedApp = client.getMobileAppById(searchApp.getId().getId());
assertEquals(searchApp.getPkgName(), fetchedApp.getPkgName());
assertEquals(searchApp.getTitle(), fetchedApp.getTitle());
assertEquals(searchApp.getPlatformType(), fetchedApp.getPlatformType());
// update mobile app
MobileApp appToUpdate = createdApps.get(2);
appToUpdate.setTitle(appToUpdate.getTitle() + "_updated");
MobileApp updatedApp = client.saveMobileApp(appToUpdate);
assertEquals(appToUpdate.getTitle(), updatedApp.getTitle());
// create mobile app bundle with android and ios apps
MobileAppBundle bundle = new MobileAppBundle();
bundle.setTitle(TEST_PREFIX + "Bundle_" + timestamp);
bundle.setDescription("Test bundle");
bundle.setAndroidAppId(createdApps.get(0).getId());
bundle.setIosAppId(createdApps.get(3).getId());
bundle.setOauth2Enabled(false);
MobileAppBundle savedBundle = client.saveMobileAppBundle(bundle, null);
assertNotNull(savedBundle);
assertNotNull(savedBundle.getId());
assertEquals(bundle.getTitle(), savedBundle.getTitle());
// get bundle info by id
MobileAppBundleInfo bundleInfo = client.getMobileAppBundleInfoById(savedBundle.getId().getId());
assertEquals(savedBundle.getTitle(), bundleInfo.getTitle());
assertEquals("Test bundle", bundleInfo.getDescription());
assertNotNull(bundleInfo.getAndroidPkgName());
assertNotNull(bundleInfo.getIosPkgName());
// list tenant bundles
PageDataMobileAppBundleInfo bundles = client.getTenantMobileAppBundleInfos(100, 0,
TEST_PREFIX + "Bundle_" + timestamp, null, null);
assertEquals(1, bundles.getData().size());
// update bundle
savedBundle.setDescription("Updated description");
MobileAppBundle updatedBundle = client.saveMobileAppBundle(savedBundle, null);
assertEquals("Updated description", updatedBundle.getDescription());
// delete bundle
client.deleteMobileAppBundle(savedBundle.getId().getId());
// verify bundle deletion
assertReturns404(() ->
client.getMobileAppBundleInfoById(savedBundle.getId().getId())
);
// delete mobile app
UUID appToDeleteId = createdApps.get(0).getId().getId();
client.deleteMobileApp(appToDeleteId);
// verify app deletion
assertReturns404(() ->
client.getMobileAppById(appToDeleteId)
);
PageDataMobileApp appsAfterDelete = client.getTenantMobileApps(100, 0, null,
null, null, null);
assertEquals(4, appsAfterDelete.getData().size());
}
}

278
application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java

@ -0,0 +1,278 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.EntityActionNotificationRuleTriggerConfig;
import org.thingsboard.client.model.EntityActionRecipientsConfig;
import org.thingsboard.client.model.EntityType;
import org.thingsboard.client.model.NotificationDeliveryMethod;
import org.thingsboard.client.model.NotificationRequest;
import org.thingsboard.client.model.NotificationRequestInfo;
import org.thingsboard.client.model.NotificationRule;
import org.thingsboard.client.model.NotificationRuleInfo;
import org.thingsboard.client.model.NotificationRuleTriggerType;
import org.thingsboard.client.model.NotificationSettings;
import org.thingsboard.client.model.NotificationTarget;
import org.thingsboard.client.model.NotificationTemplate;
import org.thingsboard.client.model.NotificationTemplateConfig;
import org.thingsboard.client.model.NotificationType;
import org.thingsboard.client.model.PageDataNotification;
import org.thingsboard.client.model.PageDataNotificationRequestInfo;
import org.thingsboard.client.model.PageDataNotificationRuleInfo;
import org.thingsboard.client.model.PageDataNotificationTarget;
import org.thingsboard.client.model.PageDataNotificationTemplate;
import org.thingsboard.client.model.PlatformUsersNotificationTargetConfig;
import org.thingsboard.client.model.TenantAdministratorsFilter;
import org.thingsboard.client.model.WebDeliveryMethodNotificationTemplate;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class NotificationApiClientTest extends AbstractApiClientTest {
@Test
public void testNotificationLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
// === 1. Notification Target CRUD ===
// Create target
TenantAdministratorsFilter usersFilter = new TenantAdministratorsFilter();
PlatformUsersNotificationTargetConfig targetConfig =
new PlatformUsersNotificationTargetConfig().usersFilter(usersFilter);
NotificationTarget target =
new NotificationTarget()
.name("Test Target " + timestamp)
._configuration(targetConfig);
NotificationTarget savedTarget = client.saveNotificationTarget(target);
assertNotNull(savedTarget);
assertNotNull(savedTarget.getId());
assertEquals("Test Target " + timestamp, savedTarget.getName());
// Get target by ID
NotificationTarget fetchedTarget =
client.getNotificationTargetById(savedTarget.getId().getId());
assertEquals(savedTarget.getName(), fetchedTarget.getName());
// List targets
PageDataNotificationTarget targetsPage =
client.getNotificationTargets(100, 0, null, null, null);
assertNotNull(targetsPage);
assertNotNull(targetsPage.getData());
assertTrue(
targetsPage.getData().stream()
.anyMatch(t -> t.getName().equals(savedTarget.getName())));
// Update target
savedTarget.setName("Updated Target " + timestamp);
NotificationTarget updatedTarget = client.saveNotificationTarget(savedTarget);
assertEquals("Updated Target " + timestamp, updatedTarget.getName());
// === 2. Notification Template CRUD ===
// Create template
WebDeliveryMethodNotificationTemplate webTemplate =
new WebDeliveryMethodNotificationTemplate()
.subject("Test Subject")
.body("Test notification body")
.enabled(true);
NotificationTemplateConfig templateConfig =
new NotificationTemplateConfig()
.putDeliveryMethodsTemplatesItem("WEB", webTemplate);
NotificationTemplate template =
new NotificationTemplate()
.name("Test Template " + timestamp)
.notificationType(NotificationType.GENERAL)
._configuration(templateConfig);
NotificationTemplate savedTemplate = client.saveNotificationTemplate(template);
assertNotNull(savedTemplate);
assertNotNull(savedTemplate.getId());
assertEquals("Test Template " + timestamp, savedTemplate.getName());
// Get template by ID
NotificationTemplate fetchedTemplate =
client.getNotificationTemplateById(savedTemplate.getId().getId());
assertEquals(savedTemplate.getName(), fetchedTemplate.getName());
assertEquals(NotificationType.GENERAL, fetchedTemplate.getNotificationType());
// List templates
PageDataNotificationTemplate templatesPage =
client.getNotificationTemplates(100, 0, null, null, null, null);
assertNotNull(templatesPage);
assertTrue(
templatesPage.getData().stream()
.anyMatch(t -> t.getName().equals(savedTemplate.getName())));
// Update template
savedTemplate.setName("Updated Template " + timestamp);
NotificationTemplate updatedTemplate = client.saveNotificationTemplate(savedTemplate);
assertEquals("Updated Template " + timestamp, updatedTemplate.getName());
// === 3. Send notification & read notifications ===
// Send notification request
NotificationRequest request =
new NotificationRequest()
.targets(List.of(savedTarget.getId().getId()))
.templateId(savedTemplate.getId());
NotificationRequest sentRequest = client.createNotificationRequest(request);
assertNotNull(sentRequest);
assertNotNull(sentRequest.getId());
// Get request by ID
NotificationRequestInfo fetchedRequest =
client.getNotificationRequestById(sentRequest.getId().getId());
assertNotNull(fetchedRequest);
// List requests
PageDataNotificationRequestInfo requestsPage =
client.getNotificationRequests(100, 0, null, null, null);
assertNotNull(requestsPage);
assertFalse(requestsPage.getData().isEmpty());
// Get notifications for current user
PageDataNotification notificationsPage =
client.getNotifications(100, 0, null, null, null, null, null);
assertNotNull(notificationsPage);
assertFalse(notificationsPage.getData().isEmpty());
// Get unread count
Integer unreadCount = client.getUnreadNotificationsCount("WEB");
assertNotNull(unreadCount);
assertTrue("Expected at least one unread notification", unreadCount > 0);
// Mark single notification as read
client.markNotificationAsRead(
notificationsPage.getData().get(0).getId().getId());
// Mark all as read
client.markAllNotificationsAsRead(null);
Integer unreadAfterMarkAll = client.getUnreadNotificationsCount(null);
assertEquals("Expected no unread notifications after marking all as read", 0, unreadAfterMarkAll.intValue());
// === 4. Notification Settings ===
NotificationSettings settings = client.getNotificationSettings();
assertNotNull(settings);
List<NotificationDeliveryMethod> deliveryMethods = client.getAvailableDeliveryMethods();
assertNotNull(deliveryMethods);
assertTrue(deliveryMethods.contains(NotificationDeliveryMethod.WEB));
// === 5. Cleanup ===
// Delete notification request
client.deleteNotificationRequest(sentRequest.getId().getId());
assertReturns404(() -> client.getNotificationRequestById(sentRequest.getId().getId()));
// Delete template
client.deleteNotificationTemplateById(savedTemplate.getId().getId());
assertReturns404(() -> client.getNotificationTemplateById(savedTemplate.getId().getId()));
// Delete target
client.deleteNotificationTargetById(savedTarget.getId().getId());
assertReturns404(() -> client.getNotificationTargetById(savedTarget.getId().getId()));
}
@Test
public void testNotificationRuleLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
// Create a target for the rule recipients
TenantAdministratorsFilter usersFilter = new TenantAdministratorsFilter();
PlatformUsersNotificationTargetConfig targetConfig =
new PlatformUsersNotificationTargetConfig().usersFilter(usersFilter);
NotificationTarget target =
new NotificationTarget()
.name("Rule Test Target " + timestamp)
._configuration(targetConfig);
NotificationTarget savedTarget = client.saveNotificationTarget(target);
// Create a template of type ENTITY_ACTION
WebDeliveryMethodNotificationTemplate webTemplate =
new WebDeliveryMethodNotificationTemplate()
.subject("Entity action: ${entityType}")
.body("Entity ${entityName} was ${actionType}")
.enabled(true);
NotificationTemplateConfig templateConfig =
new NotificationTemplateConfig()
.putDeliveryMethodsTemplatesItem("WEB", webTemplate);
NotificationTemplate template =
new NotificationTemplate()
.name("Rule Test Template " + timestamp)
.notificationType(NotificationType.ENTITY_ACTION)
._configuration(templateConfig);
NotificationTemplate savedTemplate = client.saveNotificationTemplate(template);
// Build trigger config: fire on DEVICE create/update
EntityActionNotificationRuleTriggerConfig triggerConfig =
new EntityActionNotificationRuleTriggerConfig()
.addEntityTypesItem(EntityType.DEVICE)
.created(true)
.updated(true)
.deleted(false);
// Build recipients config
EntityActionRecipientsConfig recipientsConfig = new EntityActionRecipientsConfig()
.addTargetsItem(savedTarget.getId().getId());
// saveNotificationRule - create
NotificationRule rule = new NotificationRule()
.name("Test Rule " + timestamp)
.enabled(true)
.templateId(savedTemplate.getId())
.triggerType(NotificationRuleTriggerType.ENTITY_ACTION)
.triggerConfig(triggerConfig)
.recipientsConfig(recipientsConfig);
NotificationRule savedRule = client.saveNotificationRule(rule);
assertNotNull(savedRule);
assertNotNull(savedRule.getId());
assertEquals("Test Rule " + timestamp, savedRule.getName());
assertEquals(NotificationRuleTriggerType.ENTITY_ACTION, savedRule.getTriggerType());
assertEquals(Boolean.TRUE, savedRule.getEnabled());
// getNotificationRuleById
NotificationRuleInfo fetchedRule = client.getNotificationRuleById(savedRule.getId().getId());
assertNotNull(fetchedRule);
assertEquals(savedRule.getName(), fetchedRule.getName());
assertEquals(NotificationRuleTriggerType.ENTITY_ACTION, fetchedRule.getTriggerType());
// getNotificationRules - verify it appears in the list
PageDataNotificationRuleInfo rulesPage = client.getNotificationRules(100, 0, null, null, null);
assertNotNull(rulesPage);
assertTrue(rulesPage.getData().stream()
.anyMatch(r -> r.getId().getId().equals(savedRule.getId().getId())));
// deleteNotificationRule
client.deleteNotificationRule(savedRule.getId().getId());
assertReturns404(() -> client.getNotificationRuleById(savedRule.getId().getId()));
// Cleanup
client.deleteNotificationTemplateById(savedTemplate.getId().getId());
client.deleteNotificationTargetById(savedTarget.getId().getId());
}
}

138
application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java

@ -0,0 +1,138 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.MapperType;
import org.thingsboard.client.model.OAuth2BasicMapperConfig;
import org.thingsboard.client.model.OAuth2Client;
import org.thingsboard.client.model.OAuth2ClientInfo;
import org.thingsboard.client.model.OAuth2MapperConfig;
import org.thingsboard.client.model.PageDataOAuth2ClientInfo;
import org.thingsboard.client.model.PlatformType;
import org.thingsboard.client.model.TenantNameStrategyType;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class Oauth2ApiClientTest extends AbstractApiClientTest {
private OAuth2Client createOAuth2Client(String title, String clientId, String clientSecret) {
OAuth2BasicMapperConfig basicConfig = new OAuth2BasicMapperConfig();
basicConfig.setEmailAttributeKey("email");
basicConfig.setFirstNameAttributeKey("given_name");
basicConfig.setLastNameAttributeKey("family_name");
basicConfig.setTenantNameStrategy(TenantNameStrategyType.DOMAIN);
OAuth2MapperConfig mapperConfig = new OAuth2MapperConfig();
mapperConfig.setType(MapperType.BASIC);
mapperConfig.setAllowUserCreation(true);
mapperConfig.setActivateUser(false);
mapperConfig.setBasic(basicConfig);
OAuth2Client oAuth2Client = new OAuth2Client();
oAuth2Client.setTitle(title);
oAuth2Client.setClientId(clientId);
oAuth2Client.setClientSecret(clientSecret);
oAuth2Client.setAuthorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
oAuth2Client.setAccessTokenUri("https://oauth2.googleapis.com/token");
oAuth2Client.setScope(List.of("openid", "email", "profile"));
oAuth2Client.setUserInfoUri("https://openidconnect.googleapis.com/v1/userinfo");
oAuth2Client.setUserNameAttributeName("email");
oAuth2Client.setClientAuthenticationMethod("POST");
oAuth2Client.setLoginButtonLabel(title);
oAuth2Client.setMapperConfig(mapperConfig);
oAuth2Client.setPlatforms(List.of(PlatformType.WEB));
return oAuth2Client;
}
@Test
public void testOAuth2ClientLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<OAuth2Client> createdClients = new ArrayList<>();
// create 5 OAuth2 clients
for (int i = 0; i < 5; i++) {
String title = TEST_PREFIX + "OAuth2_" + timestamp + "_" + i;
OAuth2Client oAuth2Client = createOAuth2Client(title,
"client_id_" + timestamp + "_" + i,
"client_secret_" + timestamp + "_" + i);
OAuth2Client created = client.saveOAuth2Client(oAuth2Client);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(title, created.getTitle());
assertEquals("POST", created.getClientAuthenticationMethod());
assertNotNull(created.getMapperConfig());
assertEquals(MapperType.BASIC, created.getMapperConfig().getType());
createdClients.add(created);
}
// list tenant OAuth2 client infos
PageDataOAuth2ClientInfo clientInfos = client.findTenantOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertNotNull(clientInfos);
assertEquals(5, clientInfos.getData().size());
// get OAuth2 client by id
OAuth2Client searchClient = createdClients.get(2);
OAuth2Client fetchedClient = client.getOAuth2ClientById(searchClient.getId().getId());
assertEquals(searchClient.getTitle(), fetchedClient.getTitle());
assertEquals(searchClient.getClientId(), fetchedClient.getClientId());
assertEquals(searchClient.getAuthorizationUri(), fetchedClient.getAuthorizationUri());
assertEquals(3, fetchedClient.getScope().size());
// fetch client infos by ids
List<String> idsToFetch = List.of(
createdClients.get(0).getId().getId().toString(),
createdClients.get(1).getId().getId().toString()
);
List<OAuth2ClientInfo> fetchedInfos = client.findTenantOAuth2ClientInfosByIds(idsToFetch);
assertEquals(2, fetchedInfos.size());
// update OAuth2 client
OAuth2Client clientToUpdate = client.getOAuth2ClientById(createdClients.get(3).getId().getId());
clientToUpdate.setTitle(clientToUpdate.getTitle() + "_updated");
clientToUpdate.setLoginButtonLabel("Updated Login");
clientToUpdate.setPlatforms(List.of(PlatformType.WEB, PlatformType.ANDROID));
OAuth2Client updatedClient = client.saveOAuth2Client(clientToUpdate);
assertEquals(clientToUpdate.getTitle(), updatedClient.getTitle());
assertEquals("Updated Login", updatedClient.getLoginButtonLabel());
assertEquals(2, updatedClient.getPlatforms().size());
// delete OAuth2 client
UUID clientToDeleteId = createdClients.get(0).getId().getId();
client.deleteOauth2Client(clientToDeleteId);
// verify deletion
assertReturns404(() ->
client.getOAuth2ClientById(clientToDeleteId)
);
PageDataOAuth2ClientInfo clientsAfterDelete = client.findTenantOAuth2ClientInfos(100, 0,
TEST_PREFIX + "OAuth2_" + timestamp, null, null);
assertEquals(4, clientsAfterDelete.getData().size());
}
}

261
application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java

@ -0,0 +1,261 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.ChecksumAlgorithm;
import org.thingsboard.client.model.DeviceProfileId;
import org.thingsboard.client.model.DeviceProfileInfo;
import org.thingsboard.client.model.OtaPackage;
import org.thingsboard.client.model.OtaPackageInfo;
import org.thingsboard.client.model.OtaPackageType;
import org.thingsboard.client.model.PageDataOtaPackageInfo;
import org.thingsboard.client.model.SaveOtaPackageInfoRequest;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.io.File;
import java.io.FileWriter;
import java.nio.file.Files;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class OtaPackageApiClientTest extends AbstractApiClientTest {
private static final String OTA_PREFIX = "OtaTest_";
private DeviceProfileId getDefaultDeviceProfileId() throws Exception {
DeviceProfileInfo profileInfo = client.getDefaultDeviceProfileInfo();
return (DeviceProfileId) profileInfo.getId();
}
private SaveOtaPackageInfoRequest buildOtaPackageInfoRequest(
String title, String version, OtaPackageType type,
DeviceProfileId deviceProfileId, boolean usesUrl, String url) {
SaveOtaPackageInfoRequest request = new SaveOtaPackageInfoRequest();
request.setTitle(title);
request.setType(type);
request.setUrl(url);
request.setVersion(version);
request.setDeviceProfileId(deviceProfileId);
return request;
}
private OtaPackageInfo createFirmwareInfo(String suffix) throws Exception {
DeviceProfileId profileId = getDefaultDeviceProfileId();
SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest(
OTA_PREFIX + suffix, "1.0." + System.currentTimeMillis(),
OtaPackageType.FIRMWARE, profileId, false, null);
return client.saveOtaPackageInfo(request);
}
private OtaPackageInfo createFirmwareWithUrl(String suffix) throws Exception {
DeviceProfileId profileId = getDefaultDeviceProfileId();
SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest(
OTA_PREFIX + suffix, "1.0." + System.currentTimeMillis(),
OtaPackageType.FIRMWARE, profileId, true, "https://example.com/firmware.bin");
return client.saveOtaPackageInfo(request);
}
@Test
public void testSaveAndGetOtaPackageInfo() throws Exception {
long ts = System.currentTimeMillis();
DeviceProfileId profileId = getDefaultDeviceProfileId();
String title = OTA_PREFIX + "save_" + ts;
String version = "1.0." + ts;
SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest(
title, version, OtaPackageType.FIRMWARE, profileId, true, "https://example.com/fw.bin");
OtaPackageInfo saved = client.saveOtaPackageInfo(request);
assertNotNull(saved);
assertNotNull(saved.getId());
assertEquals(title, saved.getTitle());
assertEquals(version, saved.getVersion());
assertEquals(OtaPackageType.FIRMWARE, saved.getType());
assertTrue(saved.getUrl().contains("example.com"));
// get info by id
String pkgId = saved.getId().getId().toString();
OtaPackageInfo fetched = client.getOtaPackageInfoById(pkgId);
assertNotNull(fetched);
assertEquals(title, fetched.getTitle());
assertEquals(version, fetched.getVersion());
}
@Test
public void testGetOtaPackageById() throws Exception {
long ts = System.currentTimeMillis();
OtaPackageInfo saved = createFirmwareWithUrl("getbyid_" + ts);
OtaPackage fullPkg = client.getOtaPackageById(saved.getId().getId().toString());
assertNotNull(fullPkg);
assertEquals(saved.getTitle(), fullPkg.getTitle());
assertEquals(saved.getVersion(), fullPkg.getVersion());
}
@Test
public void testSaveOtaPackageInfoForSoftware() throws Exception {
long ts = System.currentTimeMillis();
DeviceProfileId profileId = getDefaultDeviceProfileId();
String title = OTA_PREFIX + "sw_" + ts;
SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest(
title, "2.0." + ts, OtaPackageType.SOFTWARE, profileId, true, "https://example.com/sw.bin");
OtaPackageInfo saved = client.saveOtaPackageInfo(request);
assertNotNull(saved);
assertEquals(OtaPackageType.SOFTWARE, saved.getType());
assertEquals(title, saved.getTitle());
}
@Test
public void testSaveOtaPackageData() throws Exception {
long ts = System.currentTimeMillis();
OtaPackageInfo info = createFirmwareInfo("data_" + ts);
File tempFile = Files.createTempFile("ota_test_", ".bin").toFile();
tempFile.deleteOnExit();
try (FileWriter writer = new FileWriter(tempFile)) {
writer.write("test firmware content " + ts);
}
OtaPackageInfo updated = client.saveOtaPackageData(
info.getId().getId().toString(), "MD5", tempFile, null);
assertNotNull(updated);
assertTrue(updated.getHasData());
assertNotNull(updated.getFileName());
assertNotNull(updated.getDataSize());
assertTrue(updated.getDataSize() > 0);
assertEquals(ChecksumAlgorithm.MD5, updated.getChecksumAlgorithm());
}
@Test
public void testDownloadOtaPackage() throws Exception {
long ts = System.currentTimeMillis();
OtaPackageInfo info = createFirmwareInfo("download_" + ts);
String content = "downloadable firmware " + ts;
File tempFile = Files.createTempFile("ota_dl_", ".bin").toFile();
tempFile.deleteOnExit();
try (FileWriter writer = new FileWriter(tempFile)) {
writer.write(content);
}
client.saveOtaPackageData(info.getId().getId().toString(), "MD5", tempFile, null);
File downloaded = client.downloadOtaPackage(info.getId().getId().toString());
assertNotNull(downloaded);
assertTrue(downloaded.length() > 0);
String downloadedContent = Files.readString(downloaded.toPath());
assertEquals(content, downloadedContent);
}
@Test
public void testDeleteOtaPackage() throws Exception {
long ts = System.currentTimeMillis();
OtaPackageInfo saved = createFirmwareWithUrl("delete_" + ts);
String pkgId = saved.getId().getId().toString();
client.getOtaPackageInfoById(pkgId);
client.deleteOtaPackage(pkgId);
assertReturns404(() -> client.getOtaPackageInfoById(pkgId));
}
@Test
public void testGetOtaPackages() throws Exception {
long ts = System.currentTimeMillis();
for (int i = 0; i < 3; i++) {
createFirmwareWithUrl("list_" + ts + "_" + i);
}
PageDataOtaPackageInfo page = client.getOtaPackages(100, 0, OTA_PREFIX + "list_" + ts, null, null);
assertNotNull(page);
assertEquals(3, page.getTotalElements().intValue());
for (OtaPackageInfo pkg : page.getData()) {
assertTrue(pkg.getTitle().startsWith(OTA_PREFIX + "list_" + ts));
}
}
@Test
public void testGetOtaPackagesByDeviceProfileAndType() throws Exception {
long ts = System.currentTimeMillis();
DeviceProfileId profileId = getDefaultDeviceProfileId();
createFirmwareWithUrl("byprofile_" + ts + "_0");
createFirmwareWithUrl("byprofile_" + ts + "_1");
PageDataOtaPackageInfo page = client.getOtaPackagesByDeviceProfileAndType(
profileId.getId().toString(), "FIRMWARE", 100, 0,
OTA_PREFIX + "byprofile_" + ts, null, null);
assertNotNull(page);
assertEquals(2, page.getTotalElements().intValue());
}
@Test
public void testGetOtaPackageInfoById_notFound() {
String nonExistentId = UUID.randomUUID().toString();
assertReturns404(() -> client.getOtaPackageInfoById(nonExistentId));
}
@Test
public void testGetOtaPackagesPagination() throws Exception {
long ts = System.currentTimeMillis();
for (int i = 0; i < 5; i++) {
createFirmwareWithUrl("paged_" + ts + "_" + i);
}
PageDataOtaPackageInfo page1 = client.getOtaPackages(2, 0, OTA_PREFIX + "paged_" + ts, null, null);
assertNotNull(page1);
assertEquals(5, page1.getTotalElements().intValue());
assertEquals(3, page1.getTotalPages().intValue());
assertEquals(2, page1.getData().size());
assertTrue(page1.getHasNext());
PageDataOtaPackageInfo lastPage = client.getOtaPackages(2, 2, OTA_PREFIX + "paged_" + ts, null, null);
assertEquals(1, lastPage.getData().size());
assertFalse(lastPage.getHasNext());
}
@Test
public void testUpdateOtaPackageInfo() throws Exception {
long ts = System.currentTimeMillis();
OtaPackageInfo saved = createFirmwareWithUrl("update_" + ts);
SaveOtaPackageInfoRequest updateReq = new SaveOtaPackageInfoRequest();
updateReq.setId(saved.getId());
updateReq.setTitle(saved.getTitle());
updateReq.setType(saved.getType());
updateReq.setVersion(saved.getVersion());
updateReq.setDeviceProfileId(saved.getDeviceProfileId());
updateReq.setUrl(saved.getUrl());
updateReq.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("infoKey", "infoValue"));
OtaPackageInfo updated = client.saveOtaPackageInfo(updateReq);
assertNotNull(updated);
assertEquals(saved.getId().getId(), updated.getId().getId());
assertEquals("infoValue", updated.getAdditionalInfo().get("infoKey").asText());
}
}

72
application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java

@ -0,0 +1,72 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.model.Device;
import org.thingsboard.server.dao.service.DaoSqlTest;
import static org.junit.Assert.assertEquals;
@DaoSqlTest
public class RpcV1ApiClientTest extends AbstractApiClientTest {
private static final String ONE_WAY_BODY =
"{\"method\":\"setGpio\",\"params\":{\"pin\":7,\"value\":1},\"persistent\":true}";
private static final String TWO_WAY_BODY =
"{\"method\":\"getGpio\",\"params\":{\"pin\":7},\"persistent\":true}";
@Test
public void testHandleOneWayDeviceRPCRequest() throws Exception {
long ts = System.currentTimeMillis();
Device device = createNewDevice(TEST_PREFIX + ts);
String deviceId = device.getId().getId().toString();
try {
client.handleOneWayDeviceRPCRequestV1(deviceId, ONE_WAY_BODY);
} catch (ApiException e) {
assertEquals("handleOneWayDeviceRPCRequest got an unexpected HTTP error: " + e.getCode(),
0, e.getCode());
}
client.deleteDevice(deviceId);
}
@Test
public void testHandleTwoWayDeviceRPCRequest() throws Exception {
long ts = System.currentTimeMillis();
Device device = createNewDevice(TEST_PREFIX + ts);
String deviceId = device.getId().getId().toString();
try {
client.handleTwoWayDeviceRPCRequestV1(deviceId, TWO_WAY_BODY);
} catch (ApiException e) {
assertEquals("handleTwoWayDeviceRPCRequest got an unexpected HTTP error: " + e.getCode(),
0, e.getCode());
}
client.deleteDevice(deviceId);
}
private Device createNewDevice(String name) throws ApiException {
Device device = new Device();
device.setName(name);
device.setType("default");
return client.saveDevice(device, null, null, null, null);
}
}

133
application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java

@ -0,0 +1,133 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.Rpc;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class RpcV2ApiClientTest extends AbstractApiClientTest {
private static final String PERSISTENT_BODY =
"{\"method\":\"setGpio\",\"params\":{\"pin\":7,\"value\":1},\"persistent\":true}";
@Test
public void testHandleOneWayDeviceRPCRequest() throws Exception {
long ts = System.currentTimeMillis();
Device device = createNewDevice(TEST_PREFIX + ts);
String deviceId = device.getId().getId().toString();
try {
client.handleOneWayDeviceRPCRequestV2(deviceId, PERSISTENT_BODY);
} catch (ApiException e) {
assertEquals("handleOneWayDeviceRPCRequest1 got an unexpected HTTP error: " + e.getCode(),
0, e.getCode());
}
client.deleteDevice(deviceId);
}
@Test
public void testHandleTwoWayDeviceRPCRequest() throws Exception {
long ts = System.currentTimeMillis();
Device device = createNewDevice(TEST_PREFIX + ts);
String deviceId = device.getId().getId().toString();
try {
client.handleTwoWayDeviceRPCRequestV2(deviceId, PERSISTENT_BODY);
} catch (ApiException e) {
assertEquals("handleTwoWayDeviceRPCRequest1 got an unexpected HTTP error: " + e.getCode(),
0, e.getCode());
}
client.deleteDevice(deviceId);
}
@Test
public void testGetPersistedRpcAndDeleteRpc() throws Exception {
long ts = System.currentTimeMillis();
Device device = createNewDevice(TEST_PREFIX + ts);
String deviceId = device.getId().getId().toString();
String rpcId = postPersistentRpcAndGetId(deviceId);
assertNotNull(rpcId);
Rpc rpc = client.getPersistedRpc(rpcId);
assertNotNull(rpc);
assertNotNull(rpc.getId());
client.deleteRpc(rpcId);
assertReturns404(() -> client.getPersistedRpc(rpcId));
client.deleteDevice(deviceId);
}
@Test
public void testGetPersistedRpcNotFound() {
assertReturns404(() -> client.getPersistedRpc(UUID.randomUUID().toString()));
}
@Test
public void testGetPersistedRpcByDevice() throws Exception {
long ts = System.currentTimeMillis();
Device device = createNewDevice(TEST_PREFIX + ts);
String deviceId = device.getId().getId().toString();
postPersistentRpcAndGetId(deviceId);
try {
client.getPersistedRpcByDevice(deviceId, 100, 0, null, null, null, null);
} catch (ApiException e) {
assertEquals("getPersistedRpcByDevice got an unexpected HTTP error: " + e.getCode(),
0, e.getCode());
}
client.deleteDevice(deviceId);
}
private Device createNewDevice(String name) throws ApiException {
Device device = new Device();
device.setName(name);
device.setType("default");
return client.saveDevice(device, null, null, null, null);
}
private String postPersistentRpcAndGetId(String deviceId) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(getBaseUrl() + "/api/plugins/rpc/oneway/" + deviceId))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + client.getToken())
.POST(HttpRequest.BodyPublishers.ofString(PERSISTENT_BODY))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
return OBJECT_MAPPER.readTree(response.body()).get("rpcId").asText();
}
}

163
application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java

@ -0,0 +1,163 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.NodeConnectionInfo;
import org.thingsboard.client.model.PageDataRuleChain;
import org.thingsboard.client.model.RuleChain;
import org.thingsboard.client.model.RuleChainMetaData;
import org.thingsboard.client.model.RuleChainType;
import org.thingsboard.client.model.RuleNode;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class RuleChainApiClientTest extends AbstractApiClientTest {
@Test
public void testRuleChainAndNodeLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<RuleChain> createdChains = new ArrayList<>();
// create 5 rule chains
for (int i = 0; i < 5; i++) {
RuleChain ruleChain = new RuleChain();
ruleChain.setName(TEST_PREFIX + "RuleChain_" + timestamp + "_" + i);
ruleChain.setType(RuleChainType.CORE);
ruleChain.setDebugMode(false);
RuleChain created = client.saveRuleChain(ruleChain);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(ruleChain.getName(), created.getName());
assertEquals(RuleChainType.CORE, created.getType());
createdChains.add(created);
}
// list rule chains with text search
PageDataRuleChain filteredChains = client.getRuleChains(100, 0, null,
TEST_PREFIX + "RuleChain_" + timestamp, null, null);
assertNotNull(filteredChains);
assertEquals(5, filteredChains.getData().size());
// get rule chain by id
RuleChain searchChain = createdChains.get(2);
RuleChain fetchedChain = client.getRuleChainById(searchChain.getId().getId().toString());
assertEquals(searchChain.getName(), fetchedChain.getName());
assertEquals(searchChain.getType(), fetchedChain.getType());
// get metadata (initially has default node)
RuleChainMetaData metadata = client.getRuleChainMetaData(searchChain.getId().getId().toString());
assertNotNull(metadata);
assertEquals(searchChain.getId().getId(), metadata.getRuleChainId().getId());
// save metadata with rule nodes and connections
RuleChainMetaData newMetadata = new RuleChainMetaData(metadata.getRuleChainId());
newMetadata.setVersion(metadata.getVersion());
newMetadata.setFirstNodeIndex(0);
// node 0: message type switch
RuleNode switchNode = new RuleNode();
switchNode.setName("Message Type Switch");
switchNode.setType("org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode");
switchNode.setConfiguration(OBJECT_MAPPER.createObjectNode().put("version", 0));
switchNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 200).put("layoutY", 150));
// node 1: log node for telemetry
RuleNode logNode = new RuleNode();
logNode.setName("Log Telemetry");
logNode.setType("org.thingsboard.rule.engine.action.TbLogNode");
logNode.setConfiguration(OBJECT_MAPPER.createObjectNode()
.put("scriptLang", "TBEL")
.put("jsScript", "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);")
.put("tbelScript", "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);"));
logNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 500).put("layoutY", 100));
// node 2: save timeseries
RuleNode saveNode = new RuleNode();
saveNode.setName("Save Timeseries");
saveNode.setType("org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode");
saveNode.setConfiguration(OBJECT_MAPPER.createObjectNode()
.put("defaultTTL", 0)
.put("skipLatestPersistence", false)
.put("useServerTs", false));
saveNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 500).put("layoutY", 250));
newMetadata.setNodes(List.of(switchNode, logNode, saveNode));
// connection: switch -> log (on "Post telemetry")
NodeConnectionInfo conn1 = new NodeConnectionInfo();
conn1.setFromIndex(0);
conn1.setToIndex(1);
conn1.setType("Post telemetry");
// connection: switch -> save timeseries (on "Post telemetry")
NodeConnectionInfo conn2 = new NodeConnectionInfo();
conn2.setFromIndex(0);
conn2.setToIndex(2);
conn2.setType("Post telemetry");
newMetadata.setConnections(List.of(conn1, conn2));
newMetadata.setRuleChainConnections(List.of());
RuleChainMetaData savedMetadata = client.saveRuleChainMetaData(newMetadata, false);
assertNotNull(savedMetadata);
assertEquals(3, savedMetadata.getNodes().size());
assertEquals(2, savedMetadata.getConnections().size());
// verify saved nodes
RuleChainMetaData fetchedMetadata = client.getRuleChainMetaData(searchChain.getId().getId().toString());
assertEquals(3, fetchedMetadata.getNodes().size());
assertTrue(fetchedMetadata.getNodes().stream()
.anyMatch(node -> "Log Telemetry".equals(node.getName())));
assertTrue(fetchedMetadata.getNodes().stream()
.anyMatch(node -> "Save Timeseries".equals(node.getName())));
// get output labels
client.getRuleChainOutputLabels(searchChain.getId().getId().toString());
// update rule chain
RuleChain chainToUpdate = createdChains.get(3);
chainToUpdate.setName(chainToUpdate.getName() + "_updated");
chainToUpdate.setDebugMode(true);
RuleChain updatedChain = client.saveRuleChain(chainToUpdate);
assertEquals(chainToUpdate.getName(), updatedChain.getName());
assertEquals(true, updatedChain.getDebugMode());
// delete rule chain
UUID chainToDeleteId = createdChains.get(0).getId().getId();
client.deleteRuleChain(chainToDeleteId.toString());
// verify deletion
assertReturns404(() ->
client.getRuleChainById(chainToDeleteId.toString())
);
PageDataRuleChain chainsAfterDelete = client.getRuleChains(100, 0, null,
TEST_PREFIX + "RuleChain_" + timestamp, null, null);
assertEquals(4, chainsAfterDelete.getData().size());
}
}

151
application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java

@ -0,0 +1,151 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.PageDataTbResourceInfo;
import org.thingsboard.client.model.ResourceExportData;
import org.thingsboard.client.model.TbImageDeleteResult;
import org.thingsboard.client.model.TbResourceInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class TbImageApiClientTest extends AbstractApiClientTest {
private File createTempImage(String name, Color color) throws IOException {
BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
g.setColor(color);
g.fillRect(0, 0, 100, 100);
g.dispose();
File tempFile = File.createTempFile(name, ".png");
tempFile.deleteOnExit();
ImageIO.write(img, "png", tempFile);
return tempFile;
}
@Test
public void testImageLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<TbResourceInfo> createdImages = new ArrayList<>();
Color[] colors = {Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW, Color.CYAN};
// upload 5 images
for (int i = 0; i < 5; i++) {
String title = TEST_PREFIX + "Image_" + timestamp + "_" + i;
File imageFile = createTempImage("test_image_" + i, colors[i]);
TbResourceInfo uploaded = client.uploadImage(imageFile, title, null);
assertNotNull(uploaded);
assertNotNull(uploaded.getResourceKey());
assertEquals(title, uploaded.getTitle());
assertNotNull(uploaded.getLink());
createdImages.add(uploaded);
}
// list images with text search
PageDataTbResourceInfo filteredImages = client.getImages(100, 0, null, false,
TEST_PREFIX + "Image_" + timestamp, null, null);
assertNotNull(filteredImages);
assertEquals(5, filteredImages.getData().size());
// get image info by type and key
TbResourceInfo searchImage = createdImages.get(2);
TbResourceInfo fetchedInfo = client.getImageInfo("tenant", searchImage.getResourceKey());
assertEquals(searchImage.getTitle(), fetchedInfo.getTitle());
assertEquals(searchImage.getResourceKey(), fetchedInfo.getResourceKey());
// download image
File downloadedImage = client.downloadImage("tenant", searchImage.getResourceKey(), null, null);
assertNotNull(downloadedImage);
assertTrue(downloadedImage.exists());
assertTrue(downloadedImage.length() > 0);
// download image preview
File preview = client.downloadImagePreview("tenant", searchImage.getResourceKey(), null, null);
assertNotNull(preview);
assertTrue(preview.exists());
assertTrue(preview.length() > 0);
// update image file
File updatedImageFile = createTempImage("updated_image", Color.MAGENTA);
TbResourceInfo updatedImage = client.updateImage("tenant", searchImage.getResourceKey(), updatedImageFile);
assertNotNull(updatedImage);
assertEquals(searchImage.getResourceKey(), updatedImage.getResourceKey());
// update image info (title)
TbResourceInfo infoToUpdate = client.getImageInfo("tenant", createdImages.get(3).getResourceKey());
infoToUpdate.setTitle(infoToUpdate.getTitle() + "_updated");
TbResourceInfo updatedInfo = client.updateImageInfo("tenant", infoToUpdate.getResourceKey(), infoToUpdate);
assertEquals(infoToUpdate.getTitle(), updatedInfo.getTitle());
// make image public
TbResourceInfo publicImage = client.updateImagePublicStatus("tenant",
createdImages.get(1).getResourceKey(), true);
assertTrue(publicImage.getPublic());
assertNotNull(publicImage.getPublicResourceKey());
assertNotNull(publicImage.getPublicLink());
// download public image
File publicDownload = client.downloadPublicImage(publicImage.getPublicResourceKey(), null, null);
assertNotNull(publicDownload);
assertTrue(publicDownload.exists());
assertTrue(publicDownload.length() > 0);
// make image private again
TbResourceInfo privateImage = client.updateImagePublicStatus("tenant",
createdImages.get(1).getResourceKey(), false);
assertEquals(false, privateImage.getPublic());
// export image
ResourceExportData exportData = client.exportImage("tenant", createdImages.get(4).getResourceKey());
assertNotNull(exportData);
assertNotNull(exportData.getData());
assertEquals(createdImages.get(4).getTitle(), exportData.getTitle());
assertEquals(createdImages.get(4).getResourceKey(), exportData.getResourceKey());
// delete image
String keyToDelete = createdImages.get(0).getResourceKey();
TbImageDeleteResult deleteResult = client.deleteImage("tenant", keyToDelete, false);
assertNotNull(deleteResult);
assertTrue(deleteResult.getSuccess());
// verify deletion
assertReturns404(() ->
client.getImageInfo("tenant", keyToDelete)
);
PageDataTbResourceInfo imagesAfterDelete = client.getImages(100, 0, null, false,
TEST_PREFIX + "Image_" + timestamp, null, null);
assertEquals(4, imagesAfterDelete.getData().size());
}
}

128
application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java

@ -0,0 +1,128 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.PageDataTbResourceInfo;
import org.thingsboard.client.model.ResourceType;
import org.thingsboard.client.model.TbResource;
import org.thingsboard.client.model.TbResourceInfo;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.io.File;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class TbResourceApiClientTest extends AbstractApiClientTest {
@Test
public void testResourceLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<TbResourceInfo> createdResources = new ArrayList<>();
// create 5 JS_MODULE resources
for (int i = 0; i < 5; i++) {
TbResource resource = new TbResource();
resource.setTitle(TEST_PREFIX + "Resource_" + timestamp + "_" + i);
resource.setResourceType(ResourceType.JS_MODULE);
resource.setResourceKey("test_module_" + timestamp + "_" + i + ".js");
resource.setFileName("test_module_" + timestamp + "_" + i + ".js");
String jsContent = "export default function test" + i + "() { return " + i + "; }";
resource.setData(Base64.getEncoder().encodeToString(jsContent.getBytes()));
TbResourceInfo created = client.saveResource(resource);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(resource.getTitle(), created.getTitle());
assertEquals(ResourceType.JS_MODULE, created.getResourceType());
createdResources.add(created);
}
// get tenant resources, check count
PageDataTbResourceInfo tenantResources = client.getTenantResources(100, 0, null, null, null);
assertNotNull(tenantResources);
assertNotNull(tenantResources.getData());
int initialSize = tenantResources.getData().size();
assertTrue("Expected at least 5 resources, but got " + initialSize, initialSize >= 5);
// find with text search
PageDataTbResourceInfo filteredResources = client.getTenantResources(100, 0,
TEST_PREFIX + "Resource_" + timestamp, null, null);
assertEquals(5, filteredResources.getData().size());
// get resources with type filter
PageDataTbResourceInfo jsResources = client.getResources(100, 0,
ResourceType.JS_MODULE.getValue(), null, TEST_PREFIX + "Resource_" + timestamp, null, null);
assertEquals(5, jsResources.getData().size());
// get resource info by id
TbResourceInfo searchResource = createdResources.get(2);
TbResourceInfo fetchedInfo = client.getResourceInfoById(searchResource.getId().getId().toString());
assertEquals(searchResource.getTitle(), fetchedInfo.getTitle());
assertEquals(searchResource.getResourceKey(), fetchedInfo.getResourceKey());
// get full resource by id (includes data)
TbResource fullResource = client.getResourceById(searchResource.getId().getId().toString());
assertNotNull(fullResource);
assertEquals(searchResource.getTitle(), fullResource.getTitle());
assertNotNull(fullResource.getData());
// download resource
File downloadedFile = client.downloadResource(searchResource.getId().getId().toString());
assertNotNull(downloadedFile);
assertTrue(downloadedFile.exists());
assertTrue(downloadedFile.length() > 0);
// get resources by list of ids
List<String> idsToFetch = List.of(
createdResources.get(0).getId().getId().toString(),
createdResources.get(1).getId().getId().toString()
);
List<TbResourceInfo> resourceList = client.getSystemOrTenantResourcesByIds(idsToFetch);
assertEquals(2, resourceList.size());
// update resource
TbResource resourceToUpdate = client.getResourceById(createdResources.get(3).getId().getId().toString());
resourceToUpdate.setTitle(resourceToUpdate.getTitle() + "_updated");
String updatedContent = "export default function updated() { return 42; }";
resourceToUpdate.setData(Base64.getEncoder().encodeToString(updatedContent.getBytes()));
TbResourceInfo updatedResource = client.saveResource(resourceToUpdate);
assertEquals(resourceToUpdate.getTitle(), updatedResource.getTitle());
// delete resource
UUID resourceToDeleteId = createdResources.get(0).getId().getId();
client.deleteResource(resourceToDeleteId.toString(), false);
// verify deletion
assertReturns404(() ->
client.getResourceInfoById(resourceToDeleteId.toString())
);
PageDataTbResourceInfo resourcesAfterDelete = client.getTenantResources(100, 0,
TEST_PREFIX + "Resource_" + timestamp, null, null);
assertEquals(4, resourcesAfterDelete.getData().size());
}
}

150
application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java

@ -0,0 +1,150 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AttributeData;
import org.thingsboard.client.model.Device;
import org.thingsboard.client.model.TsData;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class TelemetryApiClientTest extends AbstractApiClientTest {
@Test
public void testTelemetryLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
// create a device for telemetry operations
Device device = new Device();
device.setName("TelemetryTestDevice_" + timestamp);
device.setType("default");
Device createdDevice = client.saveDevice(device, null, null, null, null);
assertNotNull(createdDevice);
String entityType = "DEVICE";
String entityId = createdDevice.getId().getId().toString();
// save server-side attributes
String serverAttributes = "{\"serverAttr1\": \"value1\", \"serverAttr2\": 42}";
client.saveEntityAttributesV2(entityType, entityId, "SERVER_SCOPE", serverAttributes);
// save shared attributes
String sharedAttributes = "{\"sharedAttr1\": \"sharedValue1\", \"sharedAttr2\": true}";
client.saveEntityAttributesV2(entityType, entityId, "SHARED_SCOPE", sharedAttributes);
// get attribute keys
List<String> allKeys = client.getAttributeKeys(entityType, entityId);
assertNotNull(allKeys);
assertTrue(allKeys.containsAll(List.of("serverAttr1", "serverAttr2", "sharedAttr1", "sharedAttr2")));
// get attribute keys by scope
List<String> serverKeys = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE");
assertEquals(2 + 1, serverKeys.size()); //active attribute is automatically added to server scope
assertTrue(serverKeys.containsAll(List.of("serverAttr1", "serverAttr2", "active")));
// get attributes by scope
List<AttributeData> serverAttrs = client.getAttributesByScope(entityType, entityId, "SERVER_SCOPE", "serverAttr1,serverAttr2", null);
assertNotNull(serverAttrs);
assertEquals(2, serverAttrs.size());
// get all attributes
List<AttributeData> allAttrs = client.getAttributes(entityType, entityId, "serverAttr1,sharedAttr1", null);
assertEquals(2, allAttrs.size());
assertEquals("value1", allAttrs.stream().filter(attr -> attr.getKey().equals("serverAttr1")).findFirst().orElseThrow().getValue().toString());
assertEquals("sharedValue1", allAttrs.stream().filter(attr -> attr.getKey().equals("sharedAttr1")).findFirst().orElseThrow().getValue().toString());
// save timeseries data
long ts1 = timestamp - 60000;
long ts2 = timestamp - 30000;
long ts3 = timestamp;
String telemetryBody = "{\"ts\":" + ts1 + ",\"values\":{\"temperature\":25.5,\"humidity\":60}}";
client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody);
String telemetryBody2 = "{\"ts\":" + ts2 + ",\"values\":{\"temperature\":26.0,\"humidity\":58}}";
client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody2);
String telemetryBody3 = "{\"ts\":" + ts3 + ",\"values\":{\"temperature\":27.1,\"humidity\":55}}";
client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody3);
// get timeseries keys
List<String> tsKeys = client.getTimeseriesKeys(entityType, entityId);
assertNotNull(tsKeys);
assertEquals(2, tsKeys.size());
assertTrue(tsKeys.containsAll(List.of("humidity", "temperature")));
// get latest timeseries
Map<String, List<TsData>> latestData = client.getLatestTimeseries(entityType, entityId, "temperature,humidity", false, null);
assertNotNull(latestData);
assertNotNull(latestData.get("temperature"));
assertFalse(latestData.get("temperature").isEmpty());
assertEquals("27.1", latestData.get("temperature").get(0).getValue().toString());
// get timeseries history
Map<String, List<TsData>> historyData = client.getTimeseriesHistory(
entityType, entityId,
ts1 - 1000, ts3 + 1000, "temperature",
null, null, null, null, "NONE", "ASC", false, null);
assertNotNull(historyData);
List<TsData> tempHistory = historyData.get("temperature");
assertNotNull(tempHistory);
assertEquals(3, tempHistory.size());
assertEquals("25.5", tempHistory.get(0).getValue().toString());
assertEquals("27.1", tempHistory.get(2).getValue().toString());
// delete timeseries
client.deleteEntityTimeseries(entityType, entityId, "humidity", true, null, null, true, false, null);
List<String> keysAfterDelete = client.getTimeseriesKeys(entityType, entityId);
assertFalse(keysAfterDelete.contains("humidity"));
// delete attributes
client.deleteEntityAttributes(entityType, entityId, "SERVER_SCOPE", "serverAttr1", null);
List<String> serverKeysAfterDelete = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE");
assertFalse(serverKeysAfterDelete.contains("serverAttr1"));
assertTrue(serverKeysAfterDelete.contains("serverAttr2"));
// save device attributes using device-specific endpoint
client.saveDeviceAttributes(entityId, "SERVER_SCOPE", "{\"deviceSpecificAttr\": \"test\"}");
List<String> deviceKeys = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE");
assertTrue(deviceKeys.contains("deviceSpecificAttr"));
// delete device attributes
client.deleteDeviceAttributes(entityId, "SERVER_SCOPE", "deviceSpecificAttr", null);
List<String> deviceKeysAfterDelete = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE");
assertFalse(deviceKeysAfterDelete.contains("deviceSpecificAttr"));
// save telemetry with TTL
String ttlTelemetry = "{\"ts\":" + timestamp + ",\"values\":{\"shortLived\":99}}";
client.saveEntityTelemetryWithTTL(entityType, entityId, "ANY", 86400L, ttlTelemetry);
Map<String, List<TsData>> latestWithTtl = client.getLatestTimeseries(entityType, entityId, "shortLived", false, null);
assertNotNull(latestWithTtl.get("shortLived"));
assertEquals("99", latestWithTtl.get("shortLived").get(0).getValue().toString());
}
}

119
application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java

@ -0,0 +1,119 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.model.Authority;
import org.thingsboard.client.model.PageDataTenant;
import org.thingsboard.client.model.PageDataUser;
import org.thingsboard.client.model.Tenant;
import org.thingsboard.client.model.User;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class TenantApiClientTest extends AbstractApiClientTest {
@Test
public void testTenantLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<Tenant> createdTenants = new ArrayList<>();
// authenticate as sysadmin for tenant management
client.login("sysadmin@thingsboard.org", "sysadmin");
// create 20 tenants
for (int i = 0; i < 20; i++) {
Tenant tenant = new Tenant();
String tenantTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i;
tenant.setTitle(tenantTitle);
tenant.setEmail("tenant_" + timestamp + "_" + i + "@test.com");
tenant.setCountry("US");
tenant.setCity("City" + i);
Tenant createdTenant = client.saveTenant(tenant);
assertNotNull(createdTenant);
assertNotNull(createdTenant.getId());
assertEquals(tenantTitle, createdTenant.getTitle());
createdTenants.add(createdTenant);
}
try {
// find all with search text, check count
PageDataTenant filteredTenants = client.getTenants(100, 0, TEST_PREFIX_2, null, null);
assertEquals("Expected exactly 10 tenants matching prefix", 10, filteredTenants.getData().size());
// find by id
Tenant searchTenant = createdTenants.get(10);
Tenant fetchedTenant = client.getTenantById(searchTenant.getId().getId().toString());
assertEquals(searchTenant.getTitle(), fetchedTenant.getTitle());
assertEquals(searchTenant.getEmail(), fetchedTenant.getEmail());
// update tenant
fetchedTenant.setCity("Updated City");
fetchedTenant.setCountry("DE");
Tenant updatedTenant = client.saveTenant(fetchedTenant);
assertEquals("Updated City", updatedTenant.getCity());
assertEquals("DE", updatedTenant.getCountry());
// create a tenant admin for one of the tenants and verify listing
Tenant tenantForAdmin = createdTenants.get(0);
User adminUser = new User();
adminUser.setEmail("tenanttest_admin_" + timestamp + "@test.com");
adminUser.setAuthority(Authority.TENANT_ADMIN);
adminUser.setTenantId(tenantForAdmin.getId());
adminUser.setFirstName("TestAdmin");
User savedAdmin = client.saveUser(adminUser, "false");
assertNotNull(savedAdmin);
PageDataUser tenantAdmins = client.getTenantAdmins(
tenantForAdmin.getId().getId().toString(), 100, 0, null, null, null);
assertEquals(1, tenantAdmins.getData().size());
assertEquals(savedAdmin.getEmail(), tenantAdmins.getData().get(0).getEmail());
// delete tenant
UUID tenantToDeleteId = createdTenants.get(0).getId().getId();
client.deleteTenant(tenantToDeleteId.toString());
createdTenants.remove(0);
// verify deletion
PageDataTenant tenantsAfterDelete = client.getTenants(100, 0, TEST_PREFIX_2, null, null);
assertEquals(10, tenantsAfterDelete.getData().size());
assertReturns404(() ->
client.getTenantById(tenantToDeleteId.toString())
);
} finally {
// clean up all created tenants (deleting tenant cascades to users)
client.login("sysadmin@thingsboard.org", "sysadmin");
for (Tenant tenant : createdTenants) {
try {
client.deleteTenant(tenant.getId().getId().toString());
} catch (ApiException ignored) {
}
}
}
}
}

179
application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java

@ -0,0 +1,179 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.ApiException;
import org.thingsboard.client.model.DefaultTenantProfileConfiguration;
import org.thingsboard.client.model.EntityInfo;
import org.thingsboard.client.model.PageDataEntityInfo;
import org.thingsboard.client.model.PageDataTenantProfile;
import org.thingsboard.client.model.TenantProfile;
import org.thingsboard.client.model.TenantProfileData;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class TenantProfileApiClientTest extends AbstractApiClientTest {
@Test
public void testTenantProfileLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<TenantProfile> createdProfiles = new ArrayList<>();
// authenticate as sysadmin for tenant profile management
client.login("sysadmin@thingsboard.org", "sysadmin");
// get initial count (there should be a default profile)
PageDataTenantProfile initialProfiles = client.getTenantProfiles(100, 0, null, null, null);
assertNotNull(initialProfiles);
int initialSize = initialProfiles.getData().size();
assertTrue("Expected at least 1 default tenant profile", initialSize >= 1);
// get default tenant profile info
EntityInfo defaultProfileInfo = client.getDefaultTenantProfileInfo();
assertNotNull(defaultProfileInfo);
assertNotNull(defaultProfileInfo.getName());
try {
// create 5 tenant profiles
for (int i = 0; i < 5; i++) {
TenantProfile profile = new TenantProfile();
profile.setName(TEST_PREFIX + "TenantProfile_" + timestamp + "_" + i);
profile.setDescription("Test tenant profile " + i);
profile.setIsolatedTbRuleEngine(false);
TenantProfileData profileData = new TenantProfileData();
DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration();
config.setMaxDevices(100L);
config.setMaxAssets(100L);
config.setMaxCustomers(50L);
config.setMaxUsers(50L);
config.setMaxDashboards(50L);
config.setMaxRuleChains(20L);
config.setMaxDataPointsPerRollingArg(20L);
config.setMaxRelatedEntitiesToReturnPerCfArgument(20);
config.setMaxRelationLevelPerCfArgument(20);
profileData.setConfiguration(config);
profile.setProfileData(profileData);
profile.setDefault(false);
TenantProfile created = client.saveTenantProfile(profile);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(profile.getName(), created.getName());
assertEquals(profile.getDescription(), created.getDescription());
assertFalse(created.getDefault());
createdProfiles.add(created);
}
// find all, check count
PageDataTenantProfile allProfiles = client.getTenantProfiles(100, 0, null, null, null);
assertNotNull(allProfiles);
assertEquals(initialSize + 5, allProfiles.getData().size());
// find with text search
PageDataTenantProfile filteredProfiles = client.getTenantProfiles(100, 0,
TEST_PREFIX + "TenantProfile_" + timestamp, null, null);
assertEquals(5, filteredProfiles.getData().size());
// get by id
TenantProfile searchProfile = createdProfiles.get(2);
TenantProfile fetchedProfile = client.getTenantProfileById(searchProfile.getId().getId().toString());
assertEquals(searchProfile.getName(), fetchedProfile.getName());
assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription());
// update tenant profile
fetchedProfile.setDescription("Updated description");
TenantProfile updatedProfile = client.saveTenantProfile(fetchedProfile);
assertEquals("Updated description", updatedProfile.getDescription());
assertEquals(fetchedProfile.getName(), updatedProfile.getName());
// get tenant profile infos (paginated)
PageDataEntityInfo profileInfos = client.getTenantProfileInfos(100, 0, null, null, null);
assertNotNull(profileInfos);
assertEquals(initialSize + 5, profileInfos.getData().size());
// get profiles by list of ids
List<String> idsToFetch = List.of(
createdProfiles.get(0).getId().getId().toString(),
createdProfiles.get(1).getId().getId().toString()
);
List<TenantProfile> profileList = client.getTenantProfileList(idsToFetch);
assertEquals(2, profileList.size());
// set a profile as default
TenantProfile profileToSetDefault = createdProfiles.get(1);
client.setDefaultTenantProfile(profileToSetDefault.getId().getId().toString());
EntityInfo defaultTenantProfileInfo = client.getDefaultTenantProfileInfo();
assertEquals(profileToSetDefault.getName(), defaultTenantProfileInfo.getName());
// verify default profile info now points to the new default
EntityInfo newDefaultInfo = client.getDefaultTenantProfileInfo();
assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName());
// restore original default profile
TenantProfile originalDefault = initialProfiles.getData().stream()
.filter(TenantProfile::getDefault)
.findFirst()
.orElseThrow();
client.setDefaultTenantProfile(originalDefault.getId().getId().toString());
// delete tenant profile (cannot delete the default one)
UUID profileToDeleteId = createdProfiles.get(0).getId().getId();
client.deleteTenantProfile(profileToDeleteId.toString());
createdProfiles.remove(0);
// verify deletion
assertReturns404(() ->
client.getTenantProfileById(profileToDeleteId.toString())
);
PageDataTenantProfile profilesAfterDelete = client.getTenantProfiles(100, 0, null, null, null);
assertEquals(initialSize + 4, profilesAfterDelete.getData().size());
} finally {
// clean up created profiles
client.login("sysadmin@thingsboard.org", "sysadmin");
// ensure original default is restored before deleting test profiles
TenantProfile originalDefault = initialProfiles.getData().stream()
.filter(TenantProfile::getDefault)
.findFirst()
.orElseThrow();
try {
client.setDefaultTenantProfile(originalDefault.getId().getId().toString());
} catch (ApiException ignored) {
}
for (TenantProfile profile : createdProfiles) {
try {
client.deleteTenantProfile(profile.getId().getId().toString());
} catch (ApiException ignored) {
}
}
}
}
}

78
application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java

@ -0,0 +1,78 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.AccountTwoFaSettings;
import org.thingsboard.client.model.PlatformTwoFaSettings;
import org.thingsboard.client.model.TotpTwoFaAccountConfig;
import org.thingsboard.client.model.TotpTwoFaProviderConfig;
import org.thingsboard.client.model.TwoFaAccountConfig;
import org.thingsboard.client.model.TwoFaProviderType;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
@DaoSqlTest
public class TwoFactorAuthApiClientTest extends AbstractApiClientTest {
@Test
public void testTwoFactorAuthLifecycle() throws Exception {
// save original platform 2FA settings as sysadmin
client.login("sysadmin@thingsboard.org", "sysadmin");
// configure platform 2FA settings with TOTP provider
TotpTwoFaProviderConfig totpProviderConfig = new TotpTwoFaProviderConfig();
totpProviderConfig.setIssuerName("TestThingsBoard");
PlatformTwoFaSettings newSettings = new PlatformTwoFaSettings();
newSettings.setProviders(List.of(totpProviderConfig));
newSettings.setMinVerificationCodeSendPeriod(30);
newSettings.setTotalAllowedTimeForVerification(300);
newSettings.setMaxVerificationFailuresBeforeUserLockout(5);
PlatformTwoFaSettings savedSettings = client.savePlatformTwoFaSettings(newSettings);
assertNotNull(savedSettings);
assertNotNull(savedSettings.getProviders());
assertFalse(savedSettings.getProviders().isEmpty());
assertEquals(30, savedSettings.getMinVerificationCodeSendPeriod().intValue());
assertEquals(300, savedSettings.getTotalAllowedTimeForVerification().intValue());
// get available 2FA providers (should include TOTP)
List<TwoFaProviderType> providerTypes = client.getAvailableTwoFaProviderTypes();
assertNotNull(providerTypes);
assertTrue(providerTypes.contains(TwoFaProviderType.TOTP));
// get account 2FA settings (should be empty initially)
AccountTwoFaSettings accountSettings = client.getAccountTwoFaSettings();
assertNull(accountSettings);
// generate TOTP account config
TwoFaAccountConfig generatedConfig = client.generateTwoFaAccountConfig(TwoFaProviderType.TOTP.getValue());
assertNotNull(generatedConfig);
TotpTwoFaAccountConfig totpConfig = (TotpTwoFaAccountConfig) generatedConfig;
assertNotNull(totpConfig);
assertNotNull(totpConfig.getAuthUrl());
assertTrue(totpConfig.getAuthUrl().startsWith("otpauth://totp/"));
}
}

135
application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java

@ -0,0 +1,135 @@
/**
* Copyright © 2016-2026 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.client;
import org.junit.Test;
import org.thingsboard.client.model.Authority;
import org.thingsboard.client.model.Customer;
import org.thingsboard.client.model.JwtPair;
import org.thingsboard.client.model.PageDataUser;
import org.thingsboard.client.model.User;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class UserApiClientTest extends AbstractApiClientTest {
@Test
public void testUserLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<User> createdUsers = new ArrayList<>();
// create 20 tenant admin users
for (int i = 0; i < 20; i++) {
User user = new User();
String email = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i + "@test.com";
user.setEmail(email);
user.setAuthority(Authority.TENANT_ADMIN);
user.setTenantId(savedClientTenant.getId());
user.setFirstName("First" + i);
user.setLastName("Last" + i);
User createdUser = client.saveUser(user, "false");
assertNotNull(createdUser);
assertNotNull(createdUser.getId());
assertEquals(email, createdUser.getEmail());
assertEquals(Authority.TENANT_ADMIN, createdUser.getAuthority());
createdUsers.add(createdUser);
}
// find all tenant admins, check count (20 created + 1 from setup)
PageDataUser allUsers = client.getUsers(100, 0, null, null, null);
assertNotNull(allUsers);
assertNotNull(allUsers.getData());
int initialSize = allUsers.getData().size();
assertEquals("Expected 21 users (20 created + 2 from setup), but got " + initialSize, 22, initialSize);
// find with search text, check count
PageDataUser filteredUsers = client.getUsers(100, 0, TEST_PREFIX_2, null, null);
assertEquals("Expected exactly 10 users matching prefix", 10, filteredUsers.getData().size());
// find by id
User searchUser = createdUsers.get(10);
User fetchedUser = client.getUserById(searchUser.getId().getId().toString());
assertEquals(searchUser.getEmail(), fetchedUser.getEmail());
assertEquals(searchUser.getFirstName(), fetchedUser.getFirstName());
// update user
fetchedUser.setFirstName("UpdatedFirst");
fetchedUser.setLastName("UpdatedLast");
User updatedUser = client.saveUser(fetchedUser, "false");
assertEquals("UpdatedFirst", updatedUser.getFirstName());
assertEquals("UpdatedLast", updatedUser.getLastName());
// activate user and get token
activateUser(createdUsers.get(0).getId(), "password123", false);
JwtPair userToken = client.getUserToken(createdUsers.get(0).getId().getId().toString());
assertNotNull(userToken);
assertNotNull(userToken.getToken());
// disable user credentials
client.setUserCredentialsEnabled(createdUsers.get(0).getId().getId().toString(), "false");
// re-enable user credentials
client.setUserCredentialsEnabled(createdUsers.get(0).getId().getId().toString(), "true");
// create customer users and verify listing
Customer customer2 = new Customer();
customer2.setTitle("User test customer " + timestamp);
customer2.setEmail("usertest_" + timestamp + "@test.com");
Customer savedCustomer2 = client.saveCustomer(customer2, null, null, null);
List<User> customerUsers = new ArrayList<>();
for (int i = 0; i < 5; i++) {
User customerUser = new User();
customerUser.setEmail("custuser_" + timestamp + "_" + i + "@test.com");
customerUser.setAuthority(Authority.CUSTOMER_USER);
customerUser.setTenantId(savedClientTenant.getId());
customerUser.setCustomerId(savedCustomer2.getId());
customerUser.setFirstName("CustFirst" + i);
customerUser.setLastName("CustLast" + i);
User created = client.saveUser(customerUser, "false");
assertNotNull(created);
customerUsers.add(created);
}
// list customer users
PageDataUser customerUserPage = client.getCustomerUsers(
savedCustomer2.getId().getId().toString(), 100, 0, null, null, null);
assertEquals("Expected 5 customer users", 5, customerUserPage.getData().size());
// delete user
UUID userToDeleteId = createdUsers.get(0).getId().getId();
client.deleteUser(userToDeleteId.toString());
// verify deletion
PageDataUser usersAfterDelete = client.getUsers(100, 0, null, null, null);
assertEquals(initialSize + 5 - 1, usersAfterDelete.getData().size());
assertReturns404(() ->
client.getUserById(userToDeleteId.toString())
);
}
}

155
application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java

@ -0,0 +1,155 @@
/**
* Copyright © 2016-2026 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.client;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.Test;
import org.thingsboard.client.model.PageDataWidgetTypeInfo;
import org.thingsboard.client.model.WidgetTypeDetails;
import org.thingsboard.client.model.WidgetTypeInfo;
import org.thingsboard.client.model.WidgetsBundle;
import org.thingsboard.server.dao.service.DaoSqlTest;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
@DaoSqlTest
public class WidgetTypeApiClientTest extends AbstractApiClientTest {
private JsonNode createDescriptor(String type) {
return OBJECT_MAPPER.createObjectNode()
.put("type", type)
.put("sizeX", 7.5)
.put("sizeY", 5)
.put("resources", "[]")
.put("templateHtml", "<div class='test-widget'>Test</div>")
.put("templateCss", ".test-widget { font-size: 14px; }")
.put("controllerScript", "self.onInit = function() {};")
.put("settingsSchema", "{}")
.put("dataKeySettingsSchema", "{}");
}
@Test
public void testWidgetTypeLifecycle() throws Exception {
long timestamp = System.currentTimeMillis();
List<WidgetTypeDetails> createdWidgetTypes = new ArrayList<>();
// create a widgets bundle
WidgetsBundle bundle = new WidgetsBundle(null, null, null,
TEST_PREFIX + "Bundle_" + timestamp, null, false,
"Test bundle description", null, null);
WidgetsBundle savedBundle = client.saveWidgetsBundle(bundle);
assertNotNull(savedBundle);
assertNotNull(savedBundle.getId());
assertEquals(bundle.getTitle(), savedBundle.getTitle());
// create 5 widget types
for (int i = 0; i < 5; i++) {
String name = TEST_PREFIX + "Widget_" + timestamp + "_" + i;
JsonNode descriptor = createDescriptor("latest");
WidgetTypeDetails widgetType = new WidgetTypeDetails(null, null, null, name, descriptor);
widgetType.setDescription("Test widget " + i);
widgetType.setDeprecated(false);
widgetType.setTags(List.of("test", "automated"));
WidgetTypeDetails created = client.saveWidgetType(widgetType, false);
assertNotNull(created);
assertNotNull(created.getId());
assertEquals(name, created.getName());
assertNotNull(created.getFqn());
createdWidgetTypes.add(created);
}
// list widget types with text search (tenant only)
PageDataWidgetTypeInfo filteredTypes = client.getWidgetTypes(100, 0,
TEST_PREFIX + "Widget_" + timestamp, null, null,
true, false, null, null, null);
assertNotNull(filteredTypes);
assertEquals(5, filteredTypes.getData().size());
// get widget type details by id
WidgetTypeDetails searchWidget = createdWidgetTypes.get(2);
WidgetTypeDetails fetchedDetails = client.getWidgetTypeById(
searchWidget.getId().getId().toString(), true);
assertEquals(searchWidget.getName(), fetchedDetails.getName());
assertEquals(searchWidget.getFqn(), fetchedDetails.getFqn());
assertEquals("Test widget 2", fetchedDetails.getDescription());
// get widget type info by id
WidgetTypeInfo fetchedInfo = client.getWidgetTypeInfoById(
searchWidget.getId().getId().toString());
assertEquals(searchWidget.getName(), fetchedInfo.getName());
// add widget types to bundle
List<String> widgetTypeIds = createdWidgetTypes.stream()
.map(wt -> wt.getId().getId().toString())
.collect(Collectors.toList());
client.updateWidgetsBundleWidgetTypes(savedBundle.getId().getId().toString(), widgetTypeIds);
// get bundle widget type fqns
List<String> bundleFqns = client.getBundleWidgetTypeFqns(savedBundle.getId().getId().toString());
assertEquals(5, bundleFqns.size());
// get bundle widget types details
List<WidgetTypeDetails> bundleDetails = client.getBundleWidgetTypesDetails(
savedBundle.getId().getId().toString(), false);
assertEquals(5, bundleDetails.size());
// get bundle widget types infos (paginated)
PageDataWidgetTypeInfo bundleInfos = client.getBundleWidgetTypesInfos(
savedBundle.getId().getId().toString(), 100, 0,
null, null, null, null, null, null);
assertEquals(5, bundleInfos.getData().size());
// update widget type
WidgetTypeDetails widgetToUpdate = client.getWidgetTypeById(
createdWidgetTypes.get(3).getId().getId().toString(), true);
widgetToUpdate.setDescription("Updated description");
widgetToUpdate.setDeprecated(true);
widgetToUpdate.setTags(List.of("test", "updated"));
WidgetTypeDetails updatedWidget = client.saveWidgetType(widgetToUpdate, false);
assertEquals("Updated description", updatedWidget.getDescription());
assertEquals(true, updatedWidget.getDeprecated());
// delete widget type
String widgetToDeleteId = createdWidgetTypes.get(0).getId().getId().toString();
client.deleteWidgetType(widgetToDeleteId);
// verify deletion
assertReturns404(() ->
client.getWidgetTypeById(widgetToDeleteId, false)
);
PageDataWidgetTypeInfo typesAfterDelete = client.getWidgetTypes(100, 0,
TEST_PREFIX + "Widget_" + timestamp, null, null,
true, false, null, null, null);
assertEquals(4, typesAfterDelete.getData().size());
// delete widgets bundle
client.deleteWidgetsBundle(savedBundle.getId().getId().toString());
assertReturns404(() ->
client.getWidgetsBundleById(savedBundle.getId().getId().toString(), false)
);
}
}

12
application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java

@ -32,7 +32,6 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.server.controller.AbstractWebTest;
import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc;
import java.io.ByteArrayInputStream;
@ -56,7 +55,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.awaitility.Awaitility.await;
/**
* Tests for Edge gRPC SSL setup using the production {@link EdgeGrpcService#setupSsl} method.
* Tests for Edge gRPC SSL setup using the production {@link GrpcServer#setupSsl} method.
* <p>
* Covers:
* 1. Separate cert and key PEM inputs
@ -68,6 +67,8 @@ import static org.awaitility.Awaitility.await;
*/
class EdgeGrpcSslTest {
private static final int TIMEOUT = 30;
static {
if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
Security.addProvider(new BouncyCastleProvider());
@ -170,13 +171,14 @@ class EdgeGrpcSslTest {
Path certOnlyFile = writeTempPem("cert-only", cert);
assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null))
.isInstanceOf(IllegalArgumentException.class);
.isInstanceOf(RuntimeException.class)
.hasCauseInstanceOf(IllegalArgumentException.class);
}
// --- Server startup using production EdgeGrpcService.setupSsl() ---
private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception {
EdgeGrpcService edgeGrpcService = new EdgeGrpcService();
GrpcServer edgeGrpcService = new GrpcServer(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {});
ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource);
ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource);
ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : "");
@ -200,7 +202,7 @@ class EdgeGrpcSslTest {
.build();
channel.getState(true); // trigger connection attempt
await().atMost(AbstractWebTest.TIMEOUT, TimeUnit.SECONDS)
await().atMost(TIMEOUT, TimeUnit.SECONDS)
.pollInterval(50, TimeUnit.MILLISECONDS)
.untilAsserted(() -> {
var state = channel.getState(false);

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

@ -18,9 +18,6 @@
<logger name="org.thingsboard.server.controller.AbstractWebTest" level="INFO"/>
<logger name="org.thingsboard.server.service.script" level="INFO"/>
<!-- mute TelemetryEdgeSqlTest that causes a lot of randomly generated errors -->
<logger name="org.thingsboard.server.service.edge.rpc.PostgresEdgeGrpcSession" level="OFF"/>
<!-- LwM2m lifecycle debug for the test scope -->
<logger name="org.thingsboard.server.transport.lwm2m.server.downlink.DefaultLwM2mDownlinkMsgHandler" level="TRACE"/>
<logger name="org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler" level="TRACE"/>

18
common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java

@ -0,0 +1,18 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.util;
public record KeyValueEntry<K, V>(K key, V value) {}

6
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java

@ -30,6 +30,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@Component
@ -88,6 +89,11 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom
public void addBundleUpdateHandler(String name, Consumer<SslBundle> handler) {
// no-op
}
@Override
public void addBundleRegisterHandler(BiConsumer<String, SslBundle> handler) {
// no-op
}
};
}

2
common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java

@ -29,7 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class SsrfProtectionValidatorTest {
@ParameterizedTest

69
pom.xml

@ -38,11 +38,9 @@
<pkg.implementationTitle>${project.name}</pkg.implementationTitle>
<pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
<pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
<spring-boot.version>3.4.13</spring-boot.version>
<tomcat.version>10.1.52</tomcat.version> <!-- to fix CVE-2026-24734 and CVE-2025-66614. TODO: remove when fixed in spring-boot-dependencies -->
<jackson.version>2.18.6</jackson.version> <!-- to fix CWE-770. TODO: remove when fixed in spring-boot-dependencies -->
<thingsboard.client.version>4.4.0-SNAPSHOT</thingsboard.client.version>
<spring-boot.version>3.5.12</spring-boot.version>
<javax.xml.bind-api.version>2.4.0-b180830.0359</javax.xml.bind-api.version>
<jedis.version>5.1.5</jedis.version>
<jjwt.version>0.12.5</jjwt.version>
<rat.version>0.10</rat.version> <!-- unused -->
<cassandra.version>4.17.0</cassandra.version>
@ -67,7 +65,7 @@
<protobuf.version>3.25.5</protobuf.version> <!-- A Major v4 does not support by the pubsub yet-->
<grpc.version>1.76.0</grpc.version>
<tbel.version>1.2.9</tbel.version>
<lombok.version>1.18.40</lombok.version>
<lombok.version>1.18.44</lombok.version> <!-- must be in sync with spring-boot-dependencies; needed for maven-compiler-plugin annotationProcessorPaths -->
<paho.client.version>1.2.5</paho.client.version>
<paho.mqttv5.client.version>1.2.5</paho.mqttv5.client.version>
<os-maven-plugin.version>1.7.1</os-maven-plugin.version>
@ -91,7 +89,6 @@
<lz4.version>1.10.1</lz4.version> <!-- to fix CVE-2025-12183 and CVE-2025-66566 introduced through kafka-clients 3.9.1 TODO: remove when kafka-clients is bumped -->
<bucket4j.version>8.10.1</bucket4j.version>
<antlr.version>3.5.3</antlr.version>
<snakeyaml.version>2.2</snakeyaml.version>
<aws.sdk.version>1.12.701</aws.sdk.version>
<pubsub.client.version>1.128.1</pubsub.client.version>
@ -122,7 +119,6 @@
<perfmark-api.version>0.27.0</perfmark-api.version>
<threetenbp.version>1.7.0</threetenbp.version>
<!-- TEST SCOPE -->
<assertj.version>3.27.7</assertj.version> <!-- to fix CVE-2026-24400 (XXE). TODO: remove when fixed in spring-boot-dependencies -->
<dbunit.version>2.7.3</dbunit.version>
<java-websocket.version>1.5.6</java-websocket.version>
<mock-server.version>5.15.0</mock-server.version>
@ -921,43 +917,6 @@
<dependencyManagement>
<dependencies>
<!-- Temporary Tomcat version override -->
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-el</artifactId>
<version>${tomcat.version}</version>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-websocket</artifactId>
<version>${tomcat.version}</version>
</dependency>
<!-- End of Tomcat version override -->
<!-- Temporary Jackson version override -->
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- End of Jackson version override -->
<!-- Temporary assertj-core version override -->
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>
<!-- End of assertj-core version override -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
@ -1134,6 +1093,12 @@
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.thingsboard.client</groupId>
<artifactId>thingsboard-ce-client</artifactId>
<version>${thingsboard.client.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.thingsboard.msa</groupId>
<artifactId>js-executor</artifactId>
@ -1241,11 +1206,6 @@
<artifactId>jjwt</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>
<dependency>
<groupId>antlr</groupId>
<artifactId>antlr</artifactId>
@ -1546,12 +1506,6 @@
<version>${dbunit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
@ -1597,11 +1551,6 @@
<artifactId>bcprov-ext-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<dependency>
<groupId>com.sun.winsw</groupId>
<artifactId>winsw</artifactId>

4
rule-engine/rule-engine-components/pom.xml

@ -161,6 +161,10 @@
<groupId>org.thingsboard.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents.core5</groupId>
<artifactId>httpcore5</artifactId>
</dependency>
</dependencies>
<build>

62
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java

@ -21,6 +21,7 @@ import io.netty.handler.ssl.SslContext;
import io.netty.handler.timeout.ReadTimeoutHandler;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.core5.net.URIBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
@ -40,6 +41,7 @@ import org.thingsboard.rule.engine.credentials.BasicCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials;
import org.thingsboard.rule.engine.credentials.CredentialsType;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import reactor.netty.http.client.HttpClient;
@ -48,6 +50,7 @@ import reactor.netty.transport.ProxyProvider;
import javax.net.ssl.SSLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
@ -130,10 +133,6 @@ public class TbHttpClient {
SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
}
} else if (config.isUseSimpleClientHttpFactory()) {
if (CredentialsType.CERT_PEM == config.getCredentials().getType()) {
throw new TbNodeException("Simple HTTP Factory does not support CERT PEM credentials!");
}
} else {
SslContext sslContext = config.getCredentials().initSslContext();
httpClient = httpClient.secure(t -> t.sslContext(sslContext));
@ -219,8 +218,22 @@ public class TbHttpClient {
}
String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg);
List<KeyValueEntry<String, String>> processedQueryParams;
if (config.getQueryParams() != null) {
processedQueryParams = config.getQueryParams().stream()
.map(param -> {
var processedParamName = TbNodeUtils.processPattern(param.key(), msg);
var processedParamValue = TbNodeUtils.processPattern(param.value(), msg);
return new KeyValueEntry<>(processedParamName, processedParamValue);
})
.toList();
} else {
processedQueryParams = null;
}
HttpMethod method = HttpMethod.valueOf(config.getRequestMethod());
URI uri = buildEncodedUri(endpointUrl);
URI uri = buildEncodedUri(endpointUrl, processedQueryParams);
RequestBodySpec request = webClient
.method(method)
@ -268,7 +281,7 @@ public class TbHttpClient {
return origin;
}
public URI buildEncodedUri(String endpointUrl) {
public URI buildEncodedUri(String endpointUrl, List<KeyValueEntry<String, String>> queryParams) {
if (endpointUrl == null) {
throw new RuntimeException("Url string cannot be null!");
}
@ -276,7 +289,13 @@ public class TbHttpClient {
throw new RuntimeException("Url string cannot be empty!");
}
URI uri = UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
URI uri;
if (queryParams != null) {
uri = buildEncodedUriNew(endpointUrl, queryParams);
} else {
uri = buildEncodedUriLegacy(endpointUrl);
}
if (uri.getScheme() == null || uri.getScheme().isEmpty()) {
throw new RuntimeException("Transport scheme(protocol) must be provided!");
}
@ -292,6 +311,35 @@ public class TbHttpClient {
return uri;
}
private URI buildEncodedUriNew(String endpointUrl, List<KeyValueEntry<String, String>> queryParams) {
try {
URIBuilder builder = new URIBuilder(endpointUrl);
queryParams.forEach(param -> builder.addParameter(param.key(), param.value()));
return builder.build();
} catch (URISyntaxException e) {
throw new IllegalArgumentException(
"Invalid request URL: '" + endpointUrl + "'. The URL must be valid and properly encoded. " +
"If URL contains special characters (e.g., spaces), they must be percent-encoded (e.g., '/my device/' should be '/my%20device/').", e);
}
}
/**
* @deprecated Query params embedded in the URL string are not encoded correctly.
* <p>
* In query strings, {@code +} is interpreted as a space (URL form encoding),
* so {@code email=user+tag@test.com} becomes {@code email=user tag@test.com} on the server.
* <p>
* Pre-encoding the URL (e.g., {@code email=user%2Btag@test.com}) doesn't help
* because {@code .encode()} will double-encode it to {@code email=user%252Btag@test.com}.
* <p>
* Use {@link #buildEncodedUriNew} with a separate query params list,
* where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}.
*/
@Deprecated(forRemoval = true)
private URI buildEncodedUriLegacy(String endpointUrl) {
return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri();
}
private Object getData(TbMsg tbMsg, boolean parseToPlainText) {
String data = tbMsg.getData();
return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data);

32
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java

@ -26,14 +26,19 @@ import org.thingsboard.rule.engine.external.TbAbstractExternalNode;
import org.thingsboard.server.common.data.plugin.ComponentType;
import org.thingsboard.server.common.data.util.TbPair;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.exception.DataValidationException;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields;
@RuleNode(
type = ComponentType.EXTERNAL,
name = "rest api call",
configClazz = TbRestApiCallNodeConfiguration.class,
version = 3,
version = 4,
nodeDescription = "Invoke REST API calls to external REST server",
nodeDetails = "Will invoke REST API call <code>GET | POST | PUT | DELETE</code> to external REST server. " +
"Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." +
@ -57,7 +62,15 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
@Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
super.init(ctx);
TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
var config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class);
String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: ";
try {
validateFields(config, errorPrefix);
} catch (DataValidationException e) {
throw new TbNodeException(e, true);
}
httpClient = new TbHttpClient(config, ctx.getSharedEventLoop());
}
@ -96,6 +109,21 @@ public class TbRestApiCallNode extends TbAbstractExternalNode {
hasChanges = true;
((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256);
}
case 3:
Set<String> knownProperties = Set.of(
"restEndpointUrlPattern", "requestMethod", "headers",
"readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText",
"enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort",
"proxyUser", "proxyPassword", "credentials", "ignoreRequestBody",
"maxInMemoryBufferSizeInKb"
);
Iterator<String> fieldNames = oldConfiguration.fieldNames();
while (fieldNames.hasNext()) {
if (!knownProperties.contains(fieldNames.next())) {
hasChanges = true;
fieldNames.remove();
}
}
break;
default:
break;

38
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java

@ -15,25 +15,31 @@
*/
package org.thingsboard.rule.engine.rest;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.rule.engine.credentials.AnonymousCredentials;
import org.thingsboard.rule.engine.credentials.ClientCredentials;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@JsonIgnoreProperties(ignoreUnknown = true)
@Data
public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestApiCallNodeConfiguration> {
private String restEndpointUrlPattern;
private String requestMethod;
// null for legacy configs - triggers different URL encoding logic in TbHttpClient to preserve backward compatibility
// all new/modified configs since introduction of this property are forced to have non-null value by UI (and use new encoding logic)
private List<@NotNull KeyValueEntry<String, String>> queryParams;
private Map<String, String> headers;
private boolean useSimpleClientHttpFactory;
private int readTimeoutMs;
private int maxParallelRequestsCount;
private boolean parseToPlainText;
@ -43,18 +49,31 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
private int proxyPort;
private String proxyUser;
private String proxyPassword;
private String proxyScheme;
private ClientCredentials credentials;
private boolean ignoreRequestBody;
private int maxInMemoryBufferSizeInKb;
@JsonIgnore
@AssertTrue(message = "query parameter names and values must be non-null")
public boolean isValid() {
if (queryParams == null) {
return true; // null indicates legacy configuration; keep using old URL encoding logic
}
for (KeyValueEntry<String, String> queryParam : queryParams) {
if (queryParam == null || queryParam.key() == null || queryParam.value() == null) {
return false;
}
}
return true;
}
@Override
public TbRestApiCallNodeConfiguration defaultConfiguration() {
TbRestApiCallNodeConfiguration configuration = new TbRestApiCallNodeConfiguration();
var configuration = new TbRestApiCallNodeConfiguration();
configuration.setRestEndpointUrlPattern("http://localhost/api");
configuration.setRequestMethod("POST");
configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE));
configuration.setUseSimpleClientHttpFactory(false);
configuration.setQueryParams(Collections.emptyList());
configuration.setReadTimeoutMs(0);
configuration.setMaxParallelRequestsCount(0);
configuration.setParseToPlainText(false);
@ -66,10 +85,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration<TbRestA
}
public ClientCredentials getCredentials() {
if (this.credentials == null) {
return new AnonymousCredentials();
} else {
return this.credentials;
}
return Objects.requireNonNullElseGet(credentials, AnonymousCredentials::new);
}
}

2
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java

@ -37,7 +37,7 @@ import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@ResourceLock("SsrfSafeAddressResolverGroupTest")
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
class SsrfSafeAddressResolverGroupTest {
private static NioEventLoopGroup eventLoopGroup;

504
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java

@ -15,45 +15,55 @@
*/
package org.thingsboard.rule.engine.rest;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Named;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import org.mockserver.integration.ClientAndServer;
import org.springframework.util.LinkedMultiValueMap;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import java.net.URI;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.instanceOf;
import static org.hamcrest.Matchers.is;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willCallRealMethod;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockserver.integration.ClientAndServer.startClientAndServer;
import static org.mockserver.model.HttpRequest.request;
import static org.mockserver.model.HttpResponse.response;
@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration
public class TbHttpClientTest {
EventLoopGroup eventLoop;
@ -86,123 +96,461 @@ public class TbHttpClientTest {
@Test
public void testBuildSimpleUri() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "http://localhost:8080/";
URI uri = client.buildEncodedUri(url);
URI uri = client.buildEncodedUri(url, null);
Assertions.assertEquals(url, uri.toString());
}
@Test
public void testBuildUriWithoutProtocol() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "localhost:8080/";
assertThatThrownBy(() -> client.buildEncodedUri(url));
assertThatThrownBy(() -> client.buildEncodedUri(url, null));
}
@Test
public void testBuildInvalidUri() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "aaa";
assertThatThrownBy(() -> client.buildEncodedUri(url));
assertThatThrownBy(() -> client.buildEncodedUri(url, null));
}
@Test
public void testBuildUriWithSpecialSymbols() {
Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod();
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
String url = "http://192.168.1.1/data?d={\"a\": 12}";
String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D";
URI uri = client.buildEncodedUri(url);
URI uri = client.buildEncodedUri(url, null);
Assertions.assertEquals(expected, uri.toString());
}
@Test
public void testProcessMessageWithQueryParamsPatternProcessing() throws Exception {
// GIVEN
String path = "/api/notify";
try (var server = startClientAndServer("localhost", 0)) {
server.when(
request()
.withMethod("GET")
.withPath(path)
.withQueryStringParameter("email", "user+tag@test.com")
.withQueryStringParameter("device", "sensor-1")
.withQueryStringParameter("custom-header", "header-value")
.withQueryStringParameter("temp", "25.5")
.withQueryStringParameter("location", "room-1/zone-A")
).respond(
response().withStatusCode(200)
);
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path);
config.setRequestMethod("GET");
config.setQueryParams(List.of(
new KeyValueEntry<>("email", "${userEmail}"), // ${} from metadata
new KeyValueEntry<>("device", "${deviceName}"), // ${} from metadata
new KeyValueEntry<>("${dynamicParam}", "${dynamicValue}"), // ${} in both key and value
new KeyValueEntry<>("temp", "$[temperature]"), // $[] from data
new KeyValueEntry<>("location", "$[sensor.location]") // $[] from nested data
));
var metaData = new TbMsgMetaData();
metaData.putValue("userEmail", "user+tag@test.com");
metaData.putValue("deviceName", "sensor-1");
metaData.putValue("dynamicParam", "custom-header");
metaData.putValue("dynamicValue", "header-value");
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(new DeviceId(EntityId.NULL_UUID))
.metaData(metaData)
.data("""
{"temperature": 25.5, "sensor": {"location": "room-1/zone-A"}}
""")
.build();
// WHEN-THEN
processMessageAndWait(config, msg);
}
}
@Test
public void testProcessMessageWithJsonInUrlVariable() throws Exception {
String host = "localhost";
// GIVEN
String path = "/api";
String paramKey = "data";
String paramVal = "[{\"test\":\"test\"}]";
String successResponseBody = "SUCCESS";
var server = setUpDummyServer(host, path, paramKey, paramVal, successResponseBody);
String endpointUrl = String.format(
"http://%s:%d%s?%s=%s",
host, server.getPort(), path, paramKey, paramVal
);
String method = "GET";
var config = new TbRestApiCallNodeConfiguration()
.defaultConfiguration();
config.setRequestMethod(method);
config.setRestEndpointUrlPattern(endpointUrl);
config.setUseSimpleClientHttpFactory(true);
String paramValue = "[{\"test\":\"test\"}]";
try (var server = startClientAndServer("localhost", 0)) {
server.when(
request()
.withMethod("GET")
.withPath(path)
.withQueryStringParameter("data", paramValue)
).respond(
response().withStatusCode(200)
);
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path + "?data=" + paramValue);
config.setRequestMethod("GET");
config.setQueryParams(null);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(new DeviceId(UUID.randomUUID()))
.metaData(new TbMsgMetaData())
.data(TbMsg.EMPTY_JSON_OBJECT)
.build();
// WHEN-THEN
processMessageAndWait(config, msg);
}
}
private void processMessageAndWait(TbRestApiCallNodeConfiguration config, TbMsg msg) throws Exception {
var httpClient = new TbHttpClient(config, eventLoop);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(new DeviceId(EntityId.NULL_UUID))
.copyMetaData(TbMsgMetaData.EMPTY)
.data(TbMsg.EMPTY_JSON_OBJECT)
.build();
var successMsg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(msg.getOriginator())
.copyMetaData(msg.getMetaData())
.data(msg.getData())
.build();
var ctx = mock(TbContext.class);
when(ctx.transformMsg(
eq(msg),
eq(msg.getMetaData()),
eq(msg.getData())
)).thenReturn(successMsg);
var capturedData = ArgumentCaptor.forClass(String.class);
when(ctx.transformMsg(eq(msg), any(), any())).thenReturn(msg);
when(ctx.transformMsg(
eq(msg),
any(),
capturedData.capture()
)).thenReturn(successMsg);
CountDownLatch latch = new CountDownLatch(1);
var latch = new CountDownLatch(1);
var error = new AtomicReference<Throwable>();
httpClient.processMessage(ctx, msg,
m -> {
ctx.tellSuccess(msg);
ctx.tellSuccess(m);
latch.countDown();
},
(m, t) -> {
ctx.tellFailure(m, t);
error.set(t);
latch.countDown();
});
latch.await(5, TimeUnit.SECONDS);
assertTrue(latch.await(5, TimeUnit.SECONDS), "Request should complete within timeout");
assertNull(error.get(), "Request should succeed, but got: " + error.get());
verify(ctx, times(1)).tellSuccess(any());
verify(ctx, times(0)).tellFailure(any(), any());
Assertions.assertEquals(successResponseBody, capturedData.getValue());
verify(ctx).tellSuccess(any());
verify(ctx, never()).tellFailure(any(), any());
}
private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) {
var server = startClientAndServer(host, 1080);
createGetMethodExpectations(server, path, paramKey, paramVal, successResponseBody);
return server;
@ParameterizedTest(name = "{0}")
@MethodSource
public void testQueryParamsEncoding(String endpointUrl, List<KeyValueEntry<String, String>> queryParams, String expectedEncodedUrl) {
// GIVEN
Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod();
// WHEN
URI uri = client.buildEncodedUri(endpointUrl, queryParams);
// THEN
Assertions.assertEquals(expectedEncodedUrl, uri.toASCIIString());
}
private void createGetMethodExpectations(ClientAndServer server, String path, String paramKey, String paramVal, String successResponseBody) {
server.when(
request()
.withMethod("GET")
.withPath(path)
.withQueryStringParameter(paramKey, paramVal)
).respond(
response()
.withStatusCode(200)
.withBody(successResponseBody)
private static Stream<Arguments> testQueryParamsEncoding() {
return Stream.of(
Arguments.of(
Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"),
List.of(new KeyValueEntry<>("ts", "2016-08-01T09:06:06.0+02:00")),
"http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00"
),
Arguments.of(
Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"),
List.of(new KeyValueEntry<>("email", "someperson+test1289@thingsboard.io")),
"http://localhost:8080/api/user/sendActivationMail?email=someperson%2Btest1289%40thingsboard.io"
),
Arguments.of(
Named.named("plus mixed with spaces in value", "http://url/api"),
List.of(new KeyValueEntry<>("q", "a + b")),
"http://url/api?q=a%20%2B%20b"
),
Arguments.of(
Named.named("colon in value", "http://url/api"),
List.of(new KeyValueEntry<>("time", "12:00")),
"http://url/api?time=12%3A00"
),
Arguments.of(
Named.named("slash in value", "http://url"),
List.of(new KeyValueEntry<>("ref", "/home/user")),
"http://url?ref=%2Fhome%2Fuser"
),
Arguments.of(
Named.named("comma and semicolon in value", "http://url"),
List.of(new KeyValueEntry<>("l", "a,b;c")),
"http://url?l=a%2Cb%3Bc"
),
Arguments.of(
Named.named("ampersand and equals in value", "http://url"),
List.of(new KeyValueEntry<>("q", "key1=value1&key2=value2")),
"http://url?q=key1%3Dvalue1%26key2%3Dvalue2"
),
Arguments.of(
Named.named("JSON in value", "http://url"),
List.of(new KeyValueEntry<>("json", """
{
"string": "hello",
"integer": 42,
"float": 3.14,
"boolTrue": true,
"boolFalse": false,
"null": null,
"array": [
1,
"two",
true,
null
],
"object": {
"nested": "value"
}
}""")),
"http://url?json=%7B%0A%20%20%20%20%22string%22%3A%20%22hello%22%2C%0A%20%20%20%20%22integer%22%3A%2042%2C%0A%20%20%20%20%22float%22%3A%203.14%2C%0A%20%20%20%20%22boolTrue%22%3A%20true%2C%0A%20%20%20%20%22boolFalse%22%3A%20false%2C%0A%20%20%20%20%22null%22%3A%20null%2C%0A%20%20%20%20%22array%22%3A%20%5B%0A%20%20%20%20%20%20%20%201%2C%0A%20%20%20%20%20%20%20%20%22two%22%2C%0A%20%20%20%20%20%20%20%20true%2C%0A%20%20%20%20%20%20%20%20null%0A%20%20%20%20%5D%2C%0A%20%20%20%20%22object%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22nested%22%3A%20%22value%22%0A%20%20%20%20%7D%0A%7D"
),
Arguments.of(
Named.named("UTF-8 in query", "http://url/cafes"),
List.of(
new KeyValueEntry<>("nom", "Le Goût Moderne"),
new KeyValueEntry<>("назва", "У Миколи \uD83D\uDE0B")
),
"http://url/cafes?nom=Le%20Go%C3%BBt%20Moderne&%D0%BD%D0%B0%D0%B7%D0%B2%D0%B0=%D0%A3%20%D0%9C%D0%B8%D0%BA%D0%BE%D0%BB%D0%B8%20%F0%9F%98%8B"
),
Arguments.of(
Named.named("empty value", "http://url/empty"),
List.of(new KeyValueEntry<>("name", "")),
"http://url/empty?name="
),
Arguments.of(
Named.named("blank value (spaces)", "http://url/empty"),
List.of(new KeyValueEntry<>("name", " ")),
"http://url/empty?name=%20%20"
),
Arguments.of(
Named.named("empty key", "http://url/empty"),
List.of(new KeyValueEntry<>("", "value")),
"http://url/empty?=value"
),
Arguments.of(
Named.named("blank key (spaces)", "http://url/empty"),
List.of(new KeyValueEntry<>(" ", "value")),
"http://url/empty?%20%20=value"
),
Arguments.of(
Named.named("blank key with value that needs to be encoded", "http://url/empty"),
List.of(new KeyValueEntry<>(" ", "value1+value2")),
"http://url/empty?%20%20=value1%2Bvalue2"
),
Arguments.of(
Named.named("blank key and value", "http://url/empty"),
List.of(new KeyValueEntry<>(" ", " ")),
"http://url/empty?%20%20=%20%20"
),
Arguments.of(
Named.named("fragment with query params", "http://url#frag"),
List.of(new KeyValueEntry<>("docs", "مستندات عقدة القاعدة\n")),
"http://url?docs=%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%20%D8%B9%D9%82%D8%AF%D8%A9%20%D8%A7%D9%84%D9%82%D8%A7%D8%B9%D8%AF%D8%A9%0A#frag"
),
Arguments.of(
Named.named("fragment only", "http://url#frag"),
Collections.emptyList(),
"http://url#frag"
),
Arguments.of(
Named.named("multiple query params (ordered)", "http://url/api"),
List.of(
new KeyValueEntry<>("param1", "value1"),
new KeyValueEntry<>("param2", "value2"),
new KeyValueEntry<>("param3", "value3")
),
"http://url/api?param1=value1&param2=value2&param3=value3"
),
Arguments.of(
Named.named("hash (#) in value", "http://url/api"),
List.of(new KeyValueEntry<>("color", "#ff0000")),
"http://url/api?color=%23ff0000"
),
Arguments.of(
Named.named("question mark (?) in value", "http://url/api"),
List.of(new KeyValueEntry<>("query", "what?")),
"http://url/api?query=what%3F"
),
Arguments.of(
Named.named("percent sign (%) in value", "http://url/api"),
List.of(new KeyValueEntry<>("discount", "50%")),
"http://url/api?discount=50%25"
),
Arguments.of(
Named.named("already encoded string - double encoding", "http://url/api"),
List.of(new KeyValueEntry<>("encoded", "%20")),
"http://url/api?encoded=%2520"
),
Arguments.of(
Named.named("URL with port number", "http://localhost:8080/api/v1/data"),
List.of(new KeyValueEntry<>("key", "value")),
"http://localhost:8080/api/v1/data?key=value"
),
Arguments.of(
Named.named("URL with userinfo (auth)", "http://user:password@hostname/path"),
List.of(new KeyValueEntry<>("secure", "true")),
"http://user:password@hostname/path?secure=true"
),
Arguments.of(
Named.named("IPv4 address in URL", "http://192.168.1.100:9090/endpoint"),
List.of(new KeyValueEntry<>("ip", "test")),
"http://192.168.1.100:9090/endpoint?ip=test"
),
Arguments.of(
Named.named("IPv6 address in URL", "http://[::1]:8080/api"),
List.of(new KeyValueEntry<>("ipv6", "true")),
"http://[::1]:8080/api?ipv6=true"
),
Arguments.of(
Named.named("HTTPS protocol", "https://secure.example.com/api"),
List.of(new KeyValueEntry<>("token", "abc123")),
"https://secure.example.com/api?token=abc123"
),
Arguments.of(
Named.named("pipe (|) in value", "http://url/api"),
List.of(new KeyValueEntry<>("filter", "a|b|c")),
"http://url/api?filter=a%7Cb%7Cc"
),
Arguments.of(
Named.named("caret (^) and backtick (`) in value", "http://url/api"),
List.of(new KeyValueEntry<>("special", "a^b`c")),
"http://url/api?special=a%5Eb%60c"
),
Arguments.of(
Named.named("tab character in value", "http://url/api"),
List.of(new KeyValueEntry<>("data", "col1\tcol2\tcol3")),
"http://url/api?data=col1%09col2%09col3"
),
Arguments.of(
Named.named("CRLF in value", "http://url/api"),
List.of(new KeyValueEntry<>("text", "line1\r\nline2")),
"http://url/api?text=line1%0D%0Aline2"
),
Arguments.of(
Named.named("square brackets in value", "http://url/api"),
List.of(new KeyValueEntry<>("array", "[1,2,3]")),
"http://url/api?array=%5B1%2C2%2C3%5D"
),
Arguments.of(
Named.named("single and double quotes in value", "http://url/api"),
List.of(new KeyValueEntry<>("quoted", "He said \"Hello\" and 'Hi'")),
"http://url/api?quoted=He%20said%20%22Hello%22%20and%20%27Hi%27"
),
Arguments.of(
Named.named("angle brackets in value (XSS test)", "http://url/api"),
List.of(new KeyValueEntry<>("html", "<script>alert('xss')</script>")),
"http://url/api?html=%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E"
),
Arguments.of(
Named.named("backslash in value", "http://url/api"),
List.of(new KeyValueEntry<>("path", "C:\\Users\\test")),
"http://url/api?path=C%3A%5CUsers%5Ctest"
),
Arguments.of(
Named.named("at sign (@) in value", "http://url/api"),
List.of(new KeyValueEntry<>("contact", "user@domain.com")),
"http://url/api?contact=user%40domain.com"
),
Arguments.of(
Named.named("URL as value", "http://url/api"),
List.of(new KeyValueEntry<>("redirect", "https://example.com/path?foo=bar")),
"http://url/api?redirect=https%3A%2F%2Fexample.com%2Fpath%3Ffoo%3Dbar"
),
Arguments.of(
Named.named("empty list (not null)", "http://url/api"),
Collections.emptyList(),
"http://url/api"
),
Arguments.of(
Named.named("underscore and hyphen in key/value", "http://url/api"),
List.of(new KeyValueEntry<>("my-key_name", "my-value_data")),
"http://url/api?my-key_name=my-value_data"
),
Arguments.of(
Named.named("dots in key and value", "http://url/api"),
List.of(new KeyValueEntry<>("version.major", "1.2.3")),
"http://url/api?version.major=1.2.3"
),
Arguments.of(
Named.named("multiple reserved chars combined", "http://url/api"),
List.of(new KeyValueEntry<>("complex", "a=1&b=2#section?query")),
"http://url/api?complex=a%3D1%26b%3D2%23section%3Fquery"
),
Arguments.of(
Named.named("trailing slash in URL", "http://url/api/"),
List.of(new KeyValueEntry<>("param", "value")),
"http://url/api/?param=value"
),
Arguments.of(
Named.named("long value (1000 chars)", "http://url/api"),
List.of(new KeyValueEntry<>("long", "a".repeat(1000))),
"http://url/api?long=" + "a".repeat(1000)
),
Arguments.of(
Named.named("Chinese characters in value", "http://url/api"),
List.of(new KeyValueEntry<>("greeting", "你好世界")),
"http://url/api?greeting=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C"
),
Arguments.of(
Named.named("Japanese characters in value", "http://url/api"),
List.of(new KeyValueEntry<>("text", "こんにちは")),
"http://url/api?text=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF"
),
Arguments.of(
Named.named("existing query params in URL", "http://url/api?existing=param"),
List.of(new KeyValueEntry<>("new", "value")),
"http://url/api?existing=param&new=value"
),
Arguments.of(
Named.named("existing query and fragment in URL", "http://url/api?existing=param#section"),
List.of(new KeyValueEntry<>("additional", "data")),
"http://url/api?existing=param&additional=data#section"
),
Arguments.of(
Named.named("null byte in value", "http://url/api"),
List.of(new KeyValueEntry<>("data", "before\u0000after")),
"http://url/api?data=before%00after"
),
Arguments.of(
Named.named("form feed and vertical tab in value", "http://url/api"),
List.of(new KeyValueEntry<>("whitespace", "a\fb\u000Bc")),
"http://url/api?whitespace=a%0Cb%0Bc"
),
Arguments.of(
Named.named("tilde (unreserved, not encoded)", "http://url/api"),
List.of(new KeyValueEntry<>("pattern", "~user")),
"http://url/api?pattern=~user"
),
Arguments.of(
Named.named("asterisk in value", "http://url/api"),
List.of(new KeyValueEntry<>("wildcard", "*.txt")),
"http://url/api?wildcard=%2A.txt"
),
Arguments.of(
Named.named("curly braces in key", "http://url/api"),
List.of(new KeyValueEntry<>("{key}", "value")),
"http://url/api?%7Bkey%7D=value"
),
Arguments.of(
Named.named("curly braces in value", "http://url/api"),
List.of(new KeyValueEntry<>("data", "{value}")),
"http://url/api?data=%7Bvalue%7D"
),
Arguments.of(
Named.named("curly braces in both key and value", "http://url/api"),
List.of(new KeyValueEntry<>("{param}", "{data}")),
"http://url/api?%7Bparam%7D=%7Bdata%7D"
),
Arguments.of(
Named.named("duplicate param names with different values", "http://url/api"),
List.of(
new KeyValueEntry<>("test", "a+b"),
new KeyValueEntry<>("test", "b c")
),
"http://url/api?test=a%2Bb&test=b%20c"
)
);
}

178
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java

@ -25,6 +25,7 @@ import org.apache.http.impl.bootstrap.ServerBootstrap;
import org.apache.http.protocol.HttpContext;
import org.apache.http.protocol.HttpRequestHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.provider.Arguments;
@ -43,25 +44,35 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.rule.RuleNode;
import org.thingsboard.server.common.data.util.KeyValueEntry;
import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgDataType;
import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.exception.DataValidationException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private RuleNode ruleNode;
@Spy
private TbRestApiCallNode restNode;
@ -95,6 +106,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
}
}
@BeforeEach
public void setup() {
ruleNode = new RuleNode();
ruleNode.setId(ruleNodeId);
ruleNode.setName("Test REST API call node");
lenient().when(ctx.getSelf()).thenReturn(ruleNode);
}
@AfterEach
public void teardown() {
if (server != null) {
@ -102,6 +121,98 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
}
}
@Test
public void shouldNotAllowNullQueryParamNames() {
// GIVEN
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setQueryParams(List.of(new KeyValueEntry<>(null, "value")));
// WHEN-THEN
assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.matches(e -> ((TbNodeException) e).isUnrecoverable())
.rootCause()
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("query parameter names and values must be non-null");
}
@Test
public void shouldNotAllowNullQueryParamValues() {
// GIVEN
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
config.setQueryParams(List.of(new KeyValueEntry<>("key", null)));
// WHEN-THEN
assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.matches(e -> ((TbNodeException) e).isUnrecoverable())
.rootCause()
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("query parameter names and values must be non-null");
}
@Test
public void shouldNotAllowNullQueryParamEntries() {
// GIVEN
var config = new TbRestApiCallNodeConfiguration().defaultConfiguration();
var queryParams = new ArrayList<KeyValueEntry<String, String>>();
queryParams.add(new KeyValueEntry<>("key", "value"));
queryParams.add(null);
config.setQueryParams(queryParams);
// WHEN-THEN
assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))))
.isInstanceOf(TbNodeException.class)
.matches(e -> ((TbNodeException) e).isUnrecoverable())
.rootCause()
.isInstanceOf(DataValidationException.class)
.hasMessageContaining("must not be null");
}
@Test
public void shouldUseNewEncodingForNewNodesByDefault() {
// GIVEN-WHEN
var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration();
// THEN
assertEquals(Collections.emptyList(), defaultConfig.getQueryParams());
}
@Test
public void shouldDefaultToOldEncodingForLegacyConfigs() {
// GIVEN
String configJson = """
{
"restEndpointUrlPattern": "http://url?param=value",
"requestMethod": "GET",
"parseToPlainText": false,
"ignoreRequestBody": false,
"enableProxy": false,
"useSystemProxyProperties": false,
"proxyHost": null,
"proxyPort": 0,
"proxyUser": null,
"proxyPassword": null,
"readTimeoutMs": 0,
"maxParallelRequestsCount": 0,
"headers": {
"Content-Type": "application/json",
"X-Authorization": "Bearer eyJhbGciOi...."
},
"credentials": {
"type": "anonymous"
},
"maxInMemoryBufferSizeInKb": 256
}""";
// WHEN
var config = JacksonUtil.fromString(configJson, TbRestApiCallNodeConfiguration.class);
// THEN
assertNull(config.getQueryParams());
}
@Test
public void deleteRequestWithoutBody() throws IOException, InterruptedException {
final CountDownLatch latch = new CountDownLatch(1);
@ -214,7 +325,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of(
// config for version 2 with upgrade from version 0
Arguments.of(0,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," +
"\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," +
@ -224,13 +334,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}",
true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," +
"\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
// config for version 2 with upgrade from version 1
Arguments.of(1,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -241,13 +350,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"credentials\": {\"type\": \"anonymous\"}}",
true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," +
"\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}"),
// config for version 3 with upgrade from version 2
Arguments.of(2,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
@ -257,12 +365,62 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest {
"\"credentials\": {\"type\": \"anonymous\"}}",
true,
"{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," +
"\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," +
"\"parseToPlainText\": false,\"ignoreRequestBody\": false," +
"\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," +
"\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," +
"\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," +
"\"credentials\": {\"type\": \"anonymous\"}," +
"\"maxInMemoryBufferSizeInKb\": 256}")
"\"maxInMemoryBufferSizeInKb\": 256}"),
Arguments.of(3, """
{
"restEndpointUrlPattern": "http://localhost/api",
"requestMethod": "POST",
"useSimpleClientHttpFactory": true,
"parseToPlainText": false,
"ignoreRequestBody": false,
"enableProxy": false,
"useSystemProxyProperties": false,
"proxyScheme": null,
"proxyHost": null,
"proxyPort": 0,
"proxyUser": null,
"proxyPassword": null,
"readTimeoutMs": 0,
"maxParallelRequestsCount": 0,
"headers": {
"Content-Type": "application/json"
},
"credentials": {
"type": "anonymous"
},
"maxInMemoryBufferSizeInKb": 256,
"trimQueue": true,
"maxQueueSize": 100,
"trimDoubleQuotes": false,
"useRedisQueueForMsgPersistence": false
}""",
true, """
{
"restEndpointUrlPattern": "http://localhost/api",
"requestMethod": "POST",
"parseToPlainText": false,
"ignoreRequestBody": false,
"enableProxy": false,
"useSystemProxyProperties": false,
"proxyHost": null,
"proxyPort": 0,
"proxyUser": null,
"proxyPassword": null,
"readTimeoutMs": 0,
"maxParallelRequestsCount": 0,
"headers": {
"Content-Type": "application/json"
},
"credentials": {
"type": "anonymous"
},
"maxInMemoryBufferSizeInKb": 256
}""")
);
}

4
ui-ngx/src/app/core/interceptors/global-http-interceptor.ts

@ -129,10 +129,6 @@ export class GlobalHttpInterceptor implements HttpInterceptor {
this.showError(req.method + ': ' + req.url + '<br/>' +
errorResponse.status + ': ' + errorResponse.statusText);
}
} else if (errorResponse.status === 504) {
if (!ignoreErrors) {
this.showError('Request timeout');
}
} else {
unhandled = true;
}

18
ui-ngx/src/app/core/utils.ts

@ -23,7 +23,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid';
import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-type.models';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core';
import { serverErrorCodesTranslations } from '@shared/models/constants';
import { httpStatusMessageMap, serverErrorCodesTranslations } from '@shared/models/constants';
import { SubscriptionEntityInfo } from '@core/api/widget-api.models';
import {
CompiledTbFunction,
@ -910,11 +910,13 @@ export function parseHttpErrorMessage(errorResponse: HttpErrorResponse,
} else {
error = errorResponse.error;
}
if (error && !error.message) {
errorMessage = prepareMessageFromData(error);
} else if (error && error.message) {
if (error && error.message) {
errorMessage = error.message;
timeout = error.timeout ? error.timeout : 0;
} else if (isProxyError(errorResponse)) {
errorMessage = httpStatusMessageMap.get(errorResponse.status);
} else if (error) {
errorMessage = prepareMessageFromData(error);
} else {
errorMessage = `Unhandled error code ${error ? error.status : '\'Unknown\''}`;
}
@ -951,6 +953,14 @@ function prepareMessageFromData(data): string {
}
}
function isProxyError(errorResponse: HttpErrorResponse): boolean {
if (!httpStatusMessageMap.has(errorResponse.status)) {
return false;
}
const error = errorResponse.error;
return !error || typeof error === 'string' || (typeof error === 'object' && !error.message);
}
export const genNextLabel = (name: string, datasources: Datasource[]): string => {
let label = name;
let i = 1;

3
ui-ngx/src/app/modules/home/components/rule-node/common/common-rule-node-config.module.ts

@ -33,6 +33,7 @@ import { RelationsQueryConfigOldComponent } from './relations-query-config-old.c
import { SelectAttributesComponent } from './select-attributes.component';
import { AlarmStatusSelectComponent } from './alarm-status-select.component';
import { ExampleHintComponent } from './example-hint.component';
import { KvListConfigComponent } from '@home/components/rule-node/common/kv-list-config.component';
@NgModule({
declarations: [
@ -51,6 +52,7 @@ import { ExampleHintComponent } from './example-hint.component';
SelectAttributesComponent,
AlarmStatusSelectComponent,
ExampleHintComponent,
KvListConfigComponent
],
imports: [
CommonModule,
@ -73,6 +75,7 @@ import { ExampleHintComponent } from './example-hint.component';
SelectAttributesComponent,
AlarmStatusSelectComponent,
ExampleHintComponent,
KvListConfigComponent
]
})

59
ui-ngx/src/app/modules/home/components/rule-node/common/credentials-config.component.html

@ -15,8 +15,8 @@
limitations under the License.
-->
<section [formGroup]="credentialsConfigFormGroup" class="flex flex-col">
<mat-expansion-panel class="tb-credentials-config-panel-group" expanded="true">
<section [formGroup]="credentialsConfigFormGroup" class="tb-form-panel stroked">
<mat-expansion-panel class="tb-credentials-config-panel-group tb-settings" expanded="true">
<mat-expansion-panel-header>
<mat-panel-title translate>rule-node-config.credentials</mat-panel-title>
<mat-panel-description>
@ -24,7 +24,7 @@
</mat-panel-description>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<mat-form-field class="mat-block">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.credentials-type</mat-label>
<mat-select formControlName="type" required>
@for (credentialsType of allCredentialsTypes; track credentialsType) {
@ -40,32 +40,32 @@
</mat-error>
}
</mat-form-field>
<section class="flex flex-col">
@if (credentialsConfigFormGroup.get('type').value !== 'anonymous') {
<section class="flex flex-col">
@switch (credentialsConfigFormGroup.get('type').value) {
@case ('anonymous') {}
@case ('basic') {
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.username</mat-label>
<input required matInput formControlName="username">
@if (credentialsConfigFormGroup.get('username').hasError('required')) {
<mat-error>
{{ 'rule-node-config.username-required' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.password</mat-label>
<input type="password" [required]="passwordFieldRequired" matInput formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
@if (credentialsConfigFormGroup.get('password').hasError('required')) {
<mat-error>
{{ 'rule-node-config.password-required' | translate }}
</mat-error>
}
</mat-form-field>
}
@case ('cert.PEM') {
<div class="tb-hint">{{ 'rule-node-config.credentials-pem-hint' | translate }}</div>
@case("basic") {
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.username</mat-label>
<input required matInput formControlName="username">
@if (credentialsConfigFormGroup.get('username').hasError('required')) {
<mat-error>
{{ 'rule-node-config.username-required' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.password</mat-label>
<input type="password" [required]="passwordFieldRequired" matInput formControlName="password" autocomplete="new-password">
<tb-toggle-password matSuffix></tb-toggle-password>
@if (credentialsConfigFormGroup.get('password').hasError('required')) {
<mat-error>
{{ 'rule-node-config.password-required' | translate }}
</mat-error>
}
</mat-form-field>
}
@case("cert.PEM") {
<div class="tb-form-hint tb-primary-fill mb-4">{{ 'rule-node-config.credentials-pem-hint' | translate }}</div>
<tb-file-input formControlName="caCert"
inputId="caCertSelect"
[existingFileName]="credentialsConfigFormGroup.get('caCertFileName').value"
@ -98,7 +98,8 @@
</mat-form-field>
}
}
</section>
</section>
}
</ng-template>
</mat-expansion-panel>
</section>

84
ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts

@ -0,0 +1,84 @@
///
/// Copyright © 2016-2026 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, forwardRef, OnInit } from '@angular/core';
import {
AbstractControl,
ControlValueAccessor,
NG_VALIDATORS,
NG_VALUE_ACCESSOR,
Validator,
Validators
} from '@angular/forms';
import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-map-config-old.component';
@Component({
selector: 'tb-kv-list-config',
templateUrl: './kv-map-config-old.component.html',
styleUrls: ['./kv-map-config-old.component.scss'],
standalone: false,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => KvListConfigComponent),
multi: true
},
{
provide: NG_VALIDATORS,
useExisting: forwardRef(() => KvListConfigComponent),
multi: true,
}
]
})
export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator {
override writeValue(kvList: any): void {
const keyValsControls: Array<AbstractControl> = [];
if (Array.isArray(kvList) && kvList.length > 0) {
for (const property of kvList) {
keyValsControls.push(this.fb.group({
key: [property.key, [Validators.required]],
value: [property.value, [Validators.required]]
}));
}
}
this.kvListFormGroup.setControl('keyVals', this.fb.array(keyValsControls), {emitEvent: false});
}
public override validate() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (!kvList.length && this.required) {
return {
kvMapRequired: true
};
}
if (!this.kvListFormGroup.valid) {
return {
kvFieldsRequired: true
};
}
return null;
}
protected override updateModel() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (this.required && !kvList.length || !this.kvListFormGroup.valid) {
this.propagateChange(null);
} else {
this.propagateChange(kvList);
}
}
}

21
ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html

@ -16,13 +16,18 @@
-->
<section class="tb-kv-map-config flex flex-col" [formGroup]="kvListFormGroup">
<div class="header flex flex-1 flex-row gap-2">
<span class="cell tb-required flex-1">{{ keyText | translate }}</span>
<span class="cell tb-required flex-1">{{ valText | translate }}</span>
<span [class.!hidden]="disabled" style="width: 52px;" innerHTML="&nbsp"></span>
</div>
@if (description) {
<div class="tb-form-hint tb-primary-fill mb-4" [innerHTML]="description | translate | safe: 'html'"></div>
}
@if (keyValsFormArray().controls.length || !emptyText) {
<div class="header flex flex-1 flex-row gap-2" >
<span class="cell tb-required flex-1">{{ keyText | translate }}</span>
<span class="cell tb-required flex-1">{{ valText | translate }}</span>
<span [class.!hidden]="disabled" style="width: 52px;" innerHTML="&nbsp"></span>
</div>
}
<div class="body">
@for (keyValControl of keyValsFormArray().controls; track keyValControl) {
@for (keyValControl of keyValsFormArray().controls; track $index) {
<div class="row flex flex-row items-center justify-start gap-2"
formArrayName="keyVals"
>
@ -54,6 +59,10 @@
<mat-icon>close</mat-icon>
</button>
</div>
} @empty {
@if (emptyText) {
<div class="tb-prompt h-10 flex items-center justify-center">{{emptyText | translate}}</div>
}
}
@if (hintText) {
<div class="tb-hint" [innerHTML]="hintText | translate | safe: 'html'"></div>

10
ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.ts

@ -68,6 +68,10 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
@Input() hintText: string;
@Input() description: string;
@Input() emptyText: string;
private requiredValue: boolean;
get required(): boolean {
@ -83,11 +87,11 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
ngControl: NgControl;
private propagateChange = null;
public propagateChange = null;
constructor(public translate: TranslateService,
private injector: Injector,
private fb: FormBuilder,
protected fb: FormBuilder,
private destroyRef: DestroyRef) {
super();
}
@ -179,7 +183,7 @@ export class KvMapConfigOldComponent extends PageComponent implements ControlVal
return null;
}
private updateModel() {
protected updateModel() {
const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value;
if (this.required && !kvList.length || !this.kvListFormGroup.valid) {
this.propagateChange(null);

266
ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html

@ -15,17 +15,36 @@
limitations under the License.
-->
<section [formGroup]="restApiCallConfigForm" class="flex flex-col">
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.endpoint-url-pattern</mat-label>
<input required matInput formControlName="restEndpointUrlPattern">
@if (restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')) {
<section [formGroup]="restApiCallConfigForm" class="flex flex-col gap-4">
<div class="tb-form-panel stroked">
<div class="tb-form-panel-title" translate>rule-node-config.request-url</div>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.url</mat-label>
<input required matInput formControlName="restEndpointUrlPattern">
@if (restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')) {
<mat-error>
{{ 'rule-node-config.endpoint-url-pattern-required' | translate }}
</mat-error>
}
<mat-hint translate>rule-node-config.general-pattern-hint</mat-hint>
</mat-form-field>
<mat-hint translate>rule-node-config.url-hint</mat-hint>
</mat-form-field>
<mat-expansion-panel class="tb-settings tb-form-panel stroked no-gap" [expanded]="isQueryParamsExpanded">
<mat-expansion-panel-header>
{{ 'rule-node-config.query-params' | translate }}
<div [tb-hint-tooltip-icon]="'rule-node-config.query-params-hint' | translate" tooltipPosition="above"></div>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-kv-list-config
formControlName="queryParams"
keyText="rule-node-config.query-params-name"
keyRequiredText="rule-node-config.query-params-name-required"
valText="rule-node-config.query-params-value"
valRequiredText="rule-node-config.query-params-value-required"
emptyText="rule-node-config.query-params-empty">
</tb-kv-list-config>
</ng-template>
</mat-expansion-panel>
</div>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.request-method</mat-label>
<mat-select formControlName="requestMethod">
@ -36,121 +55,124 @@
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="enableProxy">
{{ 'rule-node-config.enable-proxy' | translate }}
</mat-checkbox>
@if (!restApiCallConfigForm.get('enableProxy').value) {
<mat-checkbox formControlName="useSimpleClientHttpFactory">
{{ 'rule-node-config.use-simple-client-http-factory' | translate }}
</mat-checkbox>
}
<mat-checkbox formControlName="parseToPlainText">
{{ 'rule-node-config.parse-to-plain-text' | translate }}
</mat-checkbox>
<div class="tb-hint" style="padding-bottom: 5px;" translate>rule-node-config.parse-to-plain-text-hint</div>
<mat-checkbox formControlName="ignoreRequestBody">
{{ 'rule-node-config.ignore-request-body' | translate }}
</mat-checkbox>
@if (restApiCallConfigForm.get('enableProxy').value) {
<div>
<mat-checkbox formControlName="useSystemProxyProperties">
{{ 'rule-node-config.use-system-proxy-properties' | translate }}
</mat-checkbox>
@if (!restApiCallConfigForm.get('useSystemProxyProperties').value) {
<div>
<div class="gt-sm:flex gt-sm:flex-row gt-sm:gap-2">
<mat-form-field class="mat-block gt-sm:max-w-10% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-scheme</mat-label>
<mat-select formControlName="proxyScheme">
@for (proxyScheme of proxySchemes; track proxyScheme) {
<mat-option [value]="proxyScheme">
{{ proxyScheme }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="md-block gt-sm:max-w-50% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-host</mat-label>
<input matInput required formControlName="proxyHost">
@if (restApiCallConfigForm.get('proxyHost').hasError('required')) {
<mat-error>
{{ 'rule-node-config.proxy-host-required' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block gt-sm:max-w-40% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-port</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1">
@if (restApiCallConfigForm.get('proxyPort').hasError('required')) {
<mat-error>
{{ 'rule-node-config.proxy-port-required' | translate }}
</mat-error>
}
@if (restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')) {
<mat-error
>
{{ 'rule-node-config.proxy-port-range' | translate }}
</mat-error>
<div class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings" [expanded]="isHeadersExpanded">
<mat-expansion-panel-header>
{{ 'rule-node-config.headers' | translate }}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<tb-kv-map-config-old
formControlName="headers"
keyText="rule-node-config.header"
keyRequiredText="rule-node-config.header-required"
valText="rule-node-config.value"
valRequiredText="rule-node-config.value-required"
description="rule-node-config.headers-hint"
emptyText="rule-node-config.headers-empty">
</tb-kv-map-config-old>
</ng-template>
</mat-expansion-panel>
</div>
<tb-credentials-config formControlName="credentials"></tb-credentials-config>
<div class="tb-form-panel stroked">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
{{ 'rule-node-config.advanced-settings' | translate }}
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="tb-form-panel stroked column">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="enableProxy">
{{ 'rule-node-config.enable-proxy' | translate }}
</mat-slide-toggle>
@if (restApiCallConfigForm.get('enableProxy').value) {
<div class="flex flex-col gap-4">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="useSystemProxyProperties">
{{ 'rule-node-config.use-system-proxy-properties' | translate }}
</mat-slide-toggle>
@if (!restApiCallConfigForm.get('useSystemProxyProperties').value) {
<div>
<div class="gt-sm:flex gt-sm:flex-row gt-sm:gap-2">
<mat-form-field class="md-block gt-sm:max-w-50% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-host</mat-label>
<input matInput required formControlName="proxyHost">
@if (restApiCallConfigForm.get('proxyHost').hasError('required')) {
<mat-error>
{{ 'rule-node-config.proxy-host-required' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block gt-sm:max-w-50% gt-sm:flex-full">
<mat-label translate>rule-node-config.proxy-port</mat-label>
<input matInput required formControlName="proxyPort" type="number" step="1">
@if (restApiCallConfigForm.get('proxyPort').hasError('required')) {
<mat-error>
{{ 'rule-node-config.proxy-port-required' | translate }}
</mat-error>
}
@if (restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')) {
<mat-error>
{{ 'rule-node-config.proxy-port-range' | translate }}
</mat-error>
}
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-user</mat-label>
<input matInput formControlName="proxyUser">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-password</mat-label>
<input matInput type="password" formControlName="proxyPassword">
<tb-toggle-password matSuffix></tb-toggle-password>
</mat-form-field>
</div>
}
</mat-form-field>
</div>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-user</mat-label>
<input matInput formControlName="proxyUser">
</mat-form-field>
<mat-form-field class="mat-block">
<mat-label translate>rule-node-config.proxy-password</mat-label>
<input matInput formControlName="proxyPassword">
</mat-form-field>
</div>
}
</div>
}
</div>
}
@if (!restApiCallConfigForm.get('useSimpleClientHttpFactory').value || restApiCallConfigForm.get('enableProxy').value) {
<mat-form-field class="mat-block"
subscriptSizing="dynamic">
<mat-label translate>rule-node-config.read-timeout</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="readTimeoutMs">
<mat-hint translate>rule-node-config.read-timeout-hint</mat-hint>
@if (restApiCallConfigForm.get('readTimeoutMs').hasError('max')) {
<mat-error
>
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
}
</mat-form-field>
}
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-parallel-requests-count</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxParallelRequestsCount">
<mat-hint translate>rule-node-config.max-parallel-requests-count-hint</mat-hint>
@if (restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')) {
<mat-error
>
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-response-size</mat-label>
<input type="text" min="1" [max]="MemoryBufferSizeInKbLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxInMemoryBufferSizeInKb">
<mat-hint translate>rule-node-config.max-response-size-hint</mat-hint>
@if (restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')) {
<mat-error
>
{{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }}
</mat-error>
}
</mat-form-field>
<label translate class="tb-title">rule-node-config.headers</label>
<div class="tb-hint" [innerHTML]="'rule-node-config.headers-hint' | translate | safe: 'html'"> </div>
<tb-kv-map-config-old
required="false"
formControlName="headers"
keyText="rule-node-config.header"
keyRequiredText="rule-node-config.header-required"
valText="rule-node-config.value"
valRequiredText="rule-node-config.value-required">
</tb-kv-map-config-old>
<tb-credentials-config formControlName="credentials" [disableCertPemCredentials]="restApiCallConfigForm.get('useSimpleClientHttpFactory').value"></tb-credentials-config>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="parseToPlainText">
<div [tb-hint-tooltip-icon]="'rule-node-config.parse-to-plain-text-hint' | translate" tooltipPosition="above">
{{ 'rule-node-config.parse-to-plain-text' | translate }}
</div>
</mat-slide-toggle>
</div>
<div class="tb-form-row">
<mat-slide-toggle class="mat-slide fixed-title-width" formControlName="ignoreRequestBody">
{{ 'rule-node-config.ignore-request-body' | translate }}
</mat-slide-toggle>
</div>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.read-timeout</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="readTimeoutMs">
<mat-hint translate>rule-node-config.read-timeout-hint</mat-hint>
@if (restApiCallConfigForm.get('readTimeoutMs').hasError('max')) {
<mat-error>
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-parallel-requests-count</mat-label>
<input type="text" min="0" [max]="IntLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxParallelRequestsCount">
<mat-hint translate>rule-node-config.max-parallel-requests-count-hint</mat-hint>
@if (restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')) {
<mat-error>
{{ 'rule-node-config.int-range' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="mat-block" subscriptSizing="dynamic">
<mat-label translate>rule-node-config.max-response-size</mat-label>
<input type="text" min="1" [max]="MemoryBufferSizeInKbLimit" inputmode="numeric" pattern="[0-9]*" matInput formControlName="maxInMemoryBufferSizeInKb">
<mat-hint translate>rule-node-config.max-response-size-hint</mat-hint>
@if (restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')) {
<mat-error>
{{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }}
</mat-error>
}
</mat-form-field>
</ng-template>
</mat-expansion-panel>
</div>
</section>

29
ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss

@ -0,0 +1,29 @@
/**
* Copyright © 2016-2026 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
:host ::ng-deep {
.mat-expansion-panel-content {
.mat-expansion-panel-body {
.tb-kv-map-config {
margin-bottom: 0;
.body {
.mdc-icon-button {
margin-bottom: 22px;
}
}
}
}
}
}

33
ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts

@ -18,22 +18,25 @@ import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models';
import { HttpRequestType, IntLimit } from '../rule-node-config.models';
import { isDefinedAndNotNull } from '@core/utils';
@Component({
selector: 'tb-external-node-rest-api-call-config',
templateUrl: './rest-api-call-config.component.html',
styleUrls: [],
styleUrls: ['./rest-api-call-config.component.scss'],
standalone: false
})
export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
restApiCallConfigForm: UntypedFormGroup;
readonly proxySchemes: string[] = ['http', 'https'];
readonly httpRequestTypes = Object.keys(HttpRequestType);
readonly MemoryBufferSizeInKbLimit = 25000;
readonly IntLimit = IntLimit;
isHeadersExpanded = false;
isQueryParamsExpanded = false;
constructor(private fb: UntypedFormBuilder) {
super();
}
@ -46,12 +49,10 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
this.restApiCallConfigForm = this.fb.group({
restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]],
requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]],
useSimpleClientHttpFactory: [configuration ? configuration.useSimpleClientHttpFactory : false, []],
parseToPlainText: [configuration ? configuration.parseToPlainText : false, []],
ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []],
enableProxy: [configuration ? configuration.enableProxy : false, []],
useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []],
proxyScheme: [configuration ? configuration.proxyHost : null, []],
proxyHost: [configuration ? configuration.proxyHost : null, []],
proxyPort: [configuration ? configuration.proxyPort : null, []],
proxyUser: [configuration ? configuration.proxyUser :null, []],
@ -60,16 +61,27 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
maxParallelRequestsCount: [configuration ? configuration.maxParallelRequestsCount : null, [Validators.min(0), Validators.max(IntLimit)]],
headers: [configuration ? configuration.headers : null, []],
credentials: [configuration ? configuration.credentials : null, []],
maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]]
maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]],
queryParams: [configuration ? configuration.queryParams : [], []],
});
}
protected validatorTriggers(): string[] {
return ['useSimpleClientHttpFactory', 'enableProxy', 'useSystemProxyProperties'];
return ['enableProxy', 'useSystemProxyProperties'];
}
protected prepareInputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration {
this.isQueryParamsExpanded = configuration && isDefinedAndNotNull(configuration.queryParams) && !!Object.keys(configuration.queryParams).length;
this.isHeadersExpanded = configuration && isDefinedAndNotNull(configuration.headers) && !!Object.keys(configuration.headers).length;
return super.prepareInputConfig(configuration);
}
protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration {
configuration.queryParams = isDefinedAndNotNull(configuration.queryParams) ? configuration.queryParams : [];
return super.prepareOutputConfig(configuration);
}
protected updateValidators(emitEvent: boolean) {
const useSimpleClientHttpFactory: boolean = this.restApiCallConfigForm.get('useSimpleClientHttpFactory').value;
const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value;
const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value;
@ -80,12 +92,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent {
} else {
this.restApiCallConfigForm.get('proxyHost').setValidators([]);
this.restApiCallConfigForm.get('proxyPort').setValidators([]);
if (useSimpleClientHttpFactory) {
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([]);
} else {
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]);
}
this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]);
}
this.restApiCallConfigForm.get('readTimeoutMs').updateValueAndValidity({emitEvent});

24
ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts

@ -433,7 +433,7 @@ export function noDataMessage(noDataDisplayMessage: string, defaultMessage: stri
return translate.instant(defaultMessage);
}
export function constructTableCssString(widgetConfig: WidgetConfig): string {
export function constructTableCssString(widgetConfig: WidgetConfig, isTabsEnabled = false): string {
const origColor = widgetConfig.color || 'rgba(0, 0, 0, 0.87)';
const origBackgroundColor = widgetConfig.backgroundColor || 'rgb(255, 255, 255)';
const currentEntityColor = 'rgba(221, 221, 221, 0.65)';
@ -451,13 +451,9 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string {
const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString();
const mdDarkDisabled2 = defaultColor.setAlpha(0.38).toRgbString();
const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString();
const cssString = ` {
let cssString = ` {
--mat-toolbar-container-text-color: ${mdDark};
--mat-tab-active-label-text-color: ${mdDark};
--mat-tab-inactive-label-text-color: ${mdDark};
--mat-tab-pagination-icon-color: ${mdDark};
--mat-tab-pagination-disabled-icon-color: ${mdDarkDisabled2};
--mat-table-header-headline-color: ${mdDarkSecondary};
--mat-table-row-item-label-text-color: ${mdDark};
--mat-icon-color: ${mdDarkSecondary};
@ -481,7 +477,19 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string {
--tb-selected-sticky-color: ${selectedStickyColor};
}
`;
return cssString;
if (isTabsEnabled) {
cssString += `
.mat-mdc-tab-group {
--mat-tab-inactive-label-text-color: ${mdDarkSecondary};
--mat-tab-pagination-icon-color: ${mdDark};
--mat-tab-pagination-disabled-icon-color: ${mdDarkDisabled2};
--mat-tab-inactive-ripple-color: ${mdDarkSecondary};
--mat-tab-inactive-focus-label-text-color: ${mdDarkSecondary};
--mat-tab-inactive-hover-label-text-color: ${mdDarkSecondary};
}
`
}
return cssString ;
}
export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKeySettings | undefined, utils: UtilsService) {

3
ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts

@ -379,8 +379,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
this.noDataDisplayMessageText =
noDataMessage(this.widgetConfig.noDataDisplayMessage, 'widget.no-data-found', this.utils, this.translate);
let cssString = constructTableCssString(this.widgetConfig);
let cssString = constructTableCssString(this.widgetConfig, true);
const origBackgroundColor = this.widgetConfig.backgroundColor || 'rgb(255, 255, 255)';
cssString += '.tb-table-widget mat-toolbar.mat-mdc-table-toolbar:not([color=primary]) {\n' +

2
ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts

@ -109,7 +109,7 @@ export class MobileAppComponent extends EntityComponent<MobileApp> {
.addValidators(Validators.required);
form.get('storeInfo.appId').addValidators(Validators.required);
} else {
form.get('storeInfo.storeLink').clearValidators();
form.get('storeInfo.storeLink').removeValidators(Validators.required);
form.get('storeInfo.sha256CertFingerprints').removeValidators(Validators.required);
form.get('storeInfo.appId').removeValidators(Validators.required);
}

9
ui-ngx/src/app/shared/components/string-items-list.component.html

@ -37,7 +37,6 @@
</mat-chip-row>
}
<input matInput type="text"
(blur)="addOnBlur($event)"
placeholder="{{ placeholder }}"
style="max-width: 300px;min-width: 250px"
#stringItemInput
@ -46,20 +45,22 @@
#origin="matAutocompleteOrigin"
[matChipInputFor]="itemsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
[matChipInputAddOnBlur]="true"
matAutocompleteOrigin
(matChipInputTokenEnd)="addOnEnd($event)"
[matAutocompleteConnectedTo]="origin"
[matAutocomplete]="stringItemAutocomplete"
[matAutocompleteDisabled]="!predefinedValues?.length">
[matAutocompleteDisabled]="!predefinedValues?.length && !fetchOptionsFn">
</mat-chip-grid>
<mat-autocomplete #stringItemAutocomplete="matAutocomplete"
[displayWith]="displayValueFn"
(optionSelected)="onOptionSelected($event)"
class="tb-autocomplete">
@for (value of filteredValues | async; track value.value) {
<mat-option [value]="value"><span [innerHTML]="value.name | highlight:searchText"></span></mat-option>
<mat-option [value]="value" (mousedown)="$event.preventDefault()"><span [innerHTML]="value.name | highlight:searchText:true"></span></mat-option>
} @empty {
@if (!allowUserValue) {
<mat-option [value]="null">{{ 'common.not-found' | translate }}</mat-option>
<mat-option [value]="null" (mousedown)="$event.preventDefault()">{{ 'common.not-found' | translate }}</mat-option>
}
}
</mat-autocomplete>

45
ui-ngx/src/app/shared/components/string-items-list.component.ts

@ -29,7 +29,7 @@ import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angula
import { coerceArray, coerceBoolean } from '@shared/decorators/coercion';
import { Observable, of } from 'rxjs';
import { filter, mergeMap, share, tap } from 'rxjs/operators';
import { MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material/autocomplete';
import { isDefined, isUndefined } from '@core/utils';
export interface StringItemsOption {
@ -37,18 +37,18 @@ export interface StringItemsOption {
value: any;
}
@Component({
selector: 'tb-string-items-list',
templateUrl: './string-items-list.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StringItemsListComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None,
standalone: false
selector: 'tb-string-items-list',
templateUrl: './string-items-list.component.html',
styleUrls: [],
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StringItemsListComponent),
multi: true
}
],
encapsulation: ViewEncapsulation.None,
standalone: false
})
export class StringItemsListComponent implements ControlValueAccessor, OnInit {
@ -149,9 +149,7 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit {
this.filteredValues = this.itemControl.valueChanges
.pipe(
tap((value) => {
if (value && typeof value !== 'string') {
this.add(value);
} else if (value === null) {
if (value === null) {
this.clear();
}
}),
@ -209,16 +207,17 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit {
this.dirty = true;
}
addOnBlur(event: FocusEvent) {
const target: HTMLElement = event.relatedTarget as HTMLElement;
if ((target && target.tagName !== 'MAT-OPTION') || this.allowUserValue) {
this.addItem(this.stringItemInput.nativeElement.value ?? '');
onOptionSelected(event: MatAutocompleteSelectedEvent): void {
if (event.option.value != null) {
this.add(event.option.value);
} else {
this.clear();
}
this.onTouched();
}
addOnEnd(event: MatChipInputEvent): void {
this.addItem(event.value ?? '')
this.addItem(event.value ?? '');
this.onTouched();
}
removeItems(item: StringItemsOption) {
@ -295,6 +294,8 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit {
this.stringItemInput.nativeElement.value = value;
this.itemControl.patchValue(value, {emitEvent: true});
setTimeout(() => {
this.stringItemInput.nativeElement.value = value;
this.itemControl.patchValue(null, {emitEvent: false});
this.stringItemInput.nativeElement.blur();
this.stringItemInput.nativeElement.focus();
}, 0);

6
ui-ngx/src/app/shared/models/constants.ts

@ -56,6 +56,12 @@ export const serverErrorCodesTranslations = new Map<number, string>([
[Constants.serverErrorCode.entitiesLimitExceeded, 'server-error.entities-limit-exceeded'],
]);
export const httpStatusMessageMap = new Map<number, string>([
[502, 'Server is temporarily unavailable (Bad Gateway)'],
[503, 'Server is temporarily unavailable'],
[504, 'Server did not respond in time (Gateway Timeout)'],
]);
export const MediaBreakpoints = {
xs: 'screen and (max-width: 599px)',
sm: 'screen and (min-width: 600px) and (max-width: 959px)',

13
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -5510,7 +5510,6 @@
"endpoint-url-pattern": "Endpoint URL pattern",
"endpoint-url-pattern-required": "Endpoint URL pattern is required",
"request-method": "Request method",
"use-simple-client-http-factory": "Use simple client HTTP factory",
"ignore-request-body": "Without request body",
"parse-to-plain-text": "Parse to plain text",
"parse-to-plain-text-hint": "If selected, request body message payload will be transformed from JSON string to plain text, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"",
@ -5666,7 +5665,6 @@
"proxy-port-range": "Proxy port should be in a range from 1 to 65535.",
"proxy-user": "Proxy user",
"proxy-password": "Proxy password",
"proxy-scheme": "Proxy scheme",
"numbers-to-template": "Phone Numbers To Template",
"numbers-to-template-required": "Phone Numbers To Template is required",
"numbers-to-template-hint": "Comma separated Phone Numbers, use <code><span style=\"color: #000;\">$&#123;</span>metadataKey<span style=\"color: #000;\">&#125;</span></code> for value from metadata, <code><span style=\"color: #000;\">$[</span>messageKey<span style=\"color: #000;\">]</span></code> for value from message body",
@ -5950,6 +5948,17 @@
"tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.",
"basic-mode": "Basic",
"advanced-mode": "Advanced",
"url-hint": "Enter the request URL. Ensure special characters are percent-encoded.",
"query-params": "Query parameters",
"query-params-hint": "Additional query parameters to include in the request URL. Both names and values support templatization and are strictly (RFC 3986) encoded after template resolution.",
"query-params-empty": "No query parameters configured",
"query-params-name": "Name",
"query-params-value": "Value",
"query-params-name-required": "Name is required",
"query-params-value-required": "Value is required",
"headers-empty": "No headers configured",
"request-url": "Request URL",
"url": "URL",
"save-time-series": {
"processing-settings": "Processing settings",
"processing-settings-hint": "Define how incoming messages are processed. Basic processing settings allow you to select preconfigured strategies, while Advanced settings allow you to select individual processing strategies for each action.",

9445
ui-ngx/src/assets/locale/locale.constant-ko_KR.json

File diff suppressed because it is too large

746
ui-ngx/structure.md

@ -0,0 +1,746 @@
# ThingsBoard CE Frontend — Navigation & Testing Guide
## 1. Quick Reference
| Key | Value |
|-----|-------|
| Angular | 20 |
| Angular Material | 20 |
| Component prefix | `tb` |
| TypeScript | 5.9 |
| RxJS | 7.8 |
**Path Aliases** (from `tsconfig.json`):
| Alias | Maps to |
|-------|---------|
| `@app/*` | `src/app/*` |
| `@env/*` | `src/environments/*` |
| `@core/*` | `src/app/core/*` |
| `@modules/*` | `src/app/modules/*` |
| `@home/*` | `src/app/modules/home/*` |
| `@shared/*` | `src/app/shared/*` |
**Key Directories:**
```
src/app/
├── core/
│ ├── http/ # HTTP services (one per entity type)
│ └── services/ # Menu, auth, utils
├── modules/
│ ├── home/
│ │ ├── components/ # Reusable home components (entity table, details panel, alarm, dashboard)
│ │ │ └── entity/ # EntitiesTableComponent, EntityDetailsPageComponent, EntityComponent base
│ │ └── pages/ # Page modules (one directory per page/feature)
│ └── login/ # Login, reset password, 2FA
└── shared/
├── components/ # Shared components (autocompletes, dialogs, time, image)
└── models/ # TypeScript interfaces/enums
```
---
## 2. Route-to-Source Mapping
All page source paths are relative to `src/app/modules/home/pages/`.
Page types: `entity-table` = EntitiesTableComponent + resolver, `entity-details` = EntityDetailsPageComponent, `settings-form` = standalone form with ConfirmOnExitGuard, `dashboard-view` = DashboardViewComponent with resolver, `custom` = unique component.
### Login (unauthenticated)
Source paths below are relative to `src/app/modules/` (not `home/pages/`).
| URL | Source | Type |
|-----|--------|------|
| `/login` | `login/pages/login/` | custom (LoginComponent) |
| `/login/resetPasswordRequest` | `login/pages/login/` | custom (ResetPasswordRequestComponent) |
| `/login/resetPassword` | `login/pages/login/` | custom (ResetPasswordComponent) |
| `/login/createPassword` | `login/pages/login/` | custom (CreatePasswordComponent) |
| `/login/mfa` | `login/pages/login/` | custom (TwoFactorAuthLoginComponent) |
### Home
| URL | Source | Type | Auth |
|-----|--------|------|------|
| `/home` | `home-links/` | custom (HomeLinksComponent + dashboard resolver) | ALL |
### Tenants (SYS_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/tenants` | `tenant/` | entity-table | `TenantsTableConfigResolver` |
| `/tenants/:entityId` | `tenant/` | entity-details | `TenantsTableConfigResolver` |
| `/tenants/:tenantId/users` | `tenant/` + `user/` | entity-table | `UsersTableConfigResolver` |
| `/tenantProfiles` | `tenant-profile/` | entity-table | `TenantProfilesTableConfigResolver` |
| `/tenantProfiles/:entityId` | `tenant-profile/` | entity-details | `TenantProfilesTableConfigResolver` |
### Alarms Center (TENANT_ADMIN, CUSTOMER_USER)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/alarms` | `alarm/` | custom (RouterTabsComponent) | — |
| `/alarms/alarms` | `alarm/` | custom (AlarmTableComponent) | — |
| `/alarms/alarm-rules` | `alarm/` | custom (AlarmRulesTableComponent) | — |
| `/alarms/alarm-rules/:entityId` | `alarm/` | entity-details | `AlarmRulesTableConfigResolver` |
### Dashboards (TENANT_ADMIN, CUSTOMER_USER)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/dashboards` | `dashboard/` | entity-table | `DashboardsTableConfigResolver` |
| `/dashboards/:dashboardId` | `dashboard/` | custom (DashboardPageComponent) | `DashboardResolver` |
### Entities (TENANT_ADMIN, CUSTOMER_USER)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/entities/devices` | `device/` | entity-table | `DevicesTableConfigResolver` |
| `/entities/devices/:entityId` | `device/` | entity-details | `DevicesTableConfigResolver` |
| `/entities/assets` | `asset/` | entity-table | `AssetsTableConfigResolver` |
| `/entities/assets/:entityId` | `asset/` | entity-details | `AssetsTableConfigResolver` |
| `/entities/entityViews` | `entity-view/` | entity-table | `EntityViewsTableConfigResolver` |
| `/entities/entityViews/:entityId` | `entity-view/` | entity-details | `EntityViewsTableConfigResolver` |
| `/entities/gateways` | `gateways/` | dashboard-view | `gatewaysDashboardResolver` |
### Profiles (TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/profiles/deviceProfiles` | `device-profile/` | entity-table | `DeviceProfilesTableConfigResolver` |
| `/profiles/deviceProfiles/:entityId` | `device-profile/` | entity-details | `DeviceProfilesTableConfigResolver` |
| `/profiles/assetProfiles` | `asset-profile/` | entity-table | `AssetProfilesTableConfigResolver` |
| `/profiles/assetProfiles/:entityId` | `asset-profile/` | entity-details | `AssetProfilesTableConfigResolver` |
### Customers (TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/customers` | `customer/` | entity-table | `CustomersTableConfigResolver` |
| `/customers/:entityId` | `customer/` | entity-details | `CustomersTableConfigResolver` |
| `/customers/:customerId/users` | `customer/` + `user/` | entity-table | `UsersTableConfigResolver` |
| `/customers/:customerId/devices` | `customer/` + `device/` | entity-table | `DevicesTableConfigResolver` |
| `/customers/:customerId/assets` | `customer/` + `asset/` | entity-table | `AssetsTableConfigResolver` |
| `/customers/:customerId/dashboards` | `customer/` + `dashboard/` | entity-table | `DashboardsTableConfigResolver` |
| `/customers/:customerId/edgeInstances` | `customer/` + `edge/` | entity-table | `EdgesTableConfigResolver` |
### Calculated Fields (TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/calculatedFields` | `calculated-fields/` | custom (CalculatedFieldsTableComponent) | — |
| `/calculatedFields/:entityId` | `calculated-fields/` | entity-details | `CalculatedFieldsTableConfigResolver` |
### Rule Chains (TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/ruleChains` | `rulechain/` | entity-table | `RuleChainsTableConfigResolver` |
| `/ruleChains/:ruleChainId` | `rulechain/` | custom (RuleChainPageComponent, lazy) | `RuleChainResolver` + others |
### Edge Management (TENANT_ADMIN, conditional on edgesSupportEnabled)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/edgeManagement/instances` | `edge/` | entity-table | `EdgesTableConfigResolver` |
| `/edgeManagement/instances/:entityId` | `edge/` | entity-details | `EdgesTableConfigResolver` |
| `/edgeManagement/instances/:edgeId/assets` | `edge/` + `asset/` | entity-table | `AssetsTableConfigResolver` |
| `/edgeManagement/instances/:edgeId/devices` | `edge/` + `device/` | entity-table | `DevicesTableConfigResolver` |
| `/edgeManagement/instances/:edgeId/entityViews` | `edge/` + `entity-view/` | entity-table | `EntityViewsTableConfigResolver` |
| `/edgeManagement/instances/:edgeId/dashboards` | `edge/` + `dashboard/` | entity-table | `DashboardsTableConfigResolver` |
| `/edgeManagement/instances/:edgeId/ruleChains` | `edge/` + `rulechain/` | entity-table | `RuleChainsTableConfigResolver` |
| `/edgeManagement/ruleChains` | `edge/` + `rulechain/` | entity-table | `RuleChainsTableConfigResolver` |
### Advanced Features (TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/features/otaUpdates` | `ota-update/` | entity-table | `OtaUpdateTableConfigResolve` |
| `/features/otaUpdates/:entityId` | `ota-update/` | entity-details | `OtaUpdateTableConfigResolve` |
| `/features/vc` | `vc/` | custom (VersionControlComponent) | — |
### Resources (SYS_ADMIN, TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/resources/widgets-library/widget-types` | `widget/` | entity-table | `WidgetTypesTableConfigResolver` |
| `/resources/widgets-library/widgets-bundles` | `widget/` | entity-table | `WidgetsBundlesTableConfigResolver` |
| `/resources/widgets-library/widgets-bundles/:widgetsBundleId` | `widget/` | custom (WidgetsBundleWidgetsComponent) | — |
| `/resources/widgets-library/widget-types/:widgetTypeId` | `widget/` | custom (WidgetEditorComponent) | — |
| `/resources/images` | `admin/` | custom (ImageGalleryComponent) | — |
| `/resources/scada-symbols` | `admin/` | custom (ImageGalleryComponent) | — |
| `/resources/scada-symbols/:type/:key` | `scada-symbol/` | custom (ScadaSymbolComponent) | — |
| `/resources/resources-library` | `admin/` | entity-table | `ResourcesLibraryTableConfigResolver` |
| `/resources/resources-library/:entityId` | `admin/` | entity-details | `ResourcesLibraryTableConfigResolver` |
| `/resources/javascript-library` | `admin/` | entity-table | `JsLibraryTableConfigResolver` |
| `/resources/javascript-library/:entityId` | `admin/` | entity-details | `JsLibraryTableConfigResolver` |
### Notifications (ALL authorities)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/notification/inbox` | `notification/inbox/` | entity-table | `InboxTableConfigResolver` |
| `/notification/sent` | `notification/sent/` | entity-table | `SentTableConfigResolver` |
| `/notification/recipients` | `notification/recipient/` | entity-table | `RecipientTableConfigResolver` |
| `/notification/templates` | `notification/template/` | entity-table | `TemplateTableConfigResolver` |
| `/notification/rules` | `notification/rule/` | entity-table | `RuleTableConfigResolver` |
### Mobile Center (SYS_ADMIN, TENANT_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/mobile-center/bundles` | `mobile/bundes/` | entity-table | `MobileBundleTableConfigResolver` |
| `/mobile-center/applications` | `mobile/applications/` | entity-table | `MobileAppTableConfigResolver` |
| `/mobile-center/applications/:entityId` | `mobile/applications/` | entity-details | `MobileAppTableConfigResolver` |
| `/mobile-center/qr-code-widget` | `mobile/qr-code-widget/` | settings-form | — |
### Settings — SYS_ADMIN
| URL | Source | Type |
|-----|--------|------|
| `/settings/general` | `admin/` | settings-form (GeneralSettingsComponent) |
| `/settings/outgoing-mail` | `admin/` | settings-form (MailServerComponent) |
| `/settings/notifications` | `admin/` | settings-form (SmsProviderComponent) |
| `/settings/queues` | `admin/` | entity-table (`QueuesTableConfigResolver`) |
### Settings — TENANT_ADMIN
| URL | Source | Type |
|-----|--------|------|
| `/settings/home` | `admin/` | settings-form (HomeSettingsComponent) |
| `/settings/notifications` | `admin/` | settings-form (SmsProviderComponent) |
| `/settings/repository` | `admin/` | settings-form (RepositoryAdminSettingsComponent) |
| `/settings/auto-commit` | `admin/` | settings-form (AutoCommitAdminSettingsComponent) |
| `/settings/trendz` | `admin/` | settings-form (TrendzSettingsComponent) |
| `/settings/ai-models` | `ai-model/` | entity-table (`AiModelsTableConfigResolver`) |
### Security Settings (SYS_ADMIN)
| URL | Source | Type | Resolver |
|-----|--------|------|----------|
| `/security-settings/general` | `admin/` | settings-form (SecuritySettingsComponent) | — |
| `/security-settings/2fa` | `admin/` | settings-form (TwoFactorAuthSettingsComponent) | — |
| `/security-settings/oauth2/domains` | `admin/` | entity-table | `DomainTableConfigResolver` |
| `/security-settings/oauth2/clients` | `admin/` | entity-table | `ClientsTableConfigResolver` |
| `/security-settings/auditLogs` | `admin/` | custom (audit log table) | — |
### Account (ALL authorities)
| URL | Source | Type |
|-----|--------|------|
| `/account/profile` | `profile/` | settings-form (ProfileComponent) |
| `/account/security` | `security/` | settings-form (SecurityComponent) |
### API Usage (TENANT_ADMIN)
| URL | Source | Type |
|-----|--------|------|
| `/usage` | `api-usage/` | dashboard-view (loads `api_usage.json`) |
---
## 3. Entity Table Pattern
Most pages follow a 3-file pattern per entity type inside `pages/<entity>/`:
```
<entity>-table-config.resolver.ts — Column definitions, actions, component wiring
<entity>.component.ts + .html — Entity detail form (extends EntityComponent<T>)
<entity>-tabs.component.ts + .html — Additional detail tabs (relations, attributes, events, etc.)
```
**Framework files** (in `home/components/entity/`):
- `entities-table.component.ts` — The generic table component. Renders columns, actions, pagination from config.
- `entity-details-page.component.ts` — Full-page entity details with tabs.
- `entity-details-panel.component.ts` — Slide-out drawer panel for entity details.
- `entity.component.ts` — Abstract base directive all entity forms extend.
- `entities-table-config.models.ts` — TypeScript interfaces for table configuration.
**Resolver structure** — each resolver returns an `EntityTableConfig<T>` with:
- `entityType` — enum value (e.g., `EntityType.DEVICE`)
- `columns` — array of column definitions (header key, cell property/template, width)
- `cellActionDescriptors` — per-row action buttons
- `groupActionDescriptors` — bulk-selection action buttons
- `headerActionDescriptors` — toolbar action buttons (add, import)
- `entityComponent` — reference to the detail form component class
- `entityTabsComponent` — reference to the tabs component class
- `addDialogComponent` — optional dialog for add flow (e.g., `DeviceWizardDialogComponent`)
### All Table Config Resolvers
| Resolver | Entity Type | Source Dir | Entity Component | Tabs Component |
|----------|-------------|-----------|------------------|----------------|
| `TenantsTableConfigResolver` | TENANT | `tenant/` | `TenantComponent` | `TenantTabsComponent` |
| `TenantProfilesTableConfigResolver` | TENANT_PROFILE | `tenant-profile/` | `TenantProfileComponent` | `TenantProfileTabsComponent` |
| `DevicesTableConfigResolver` | DEVICE | `device/` | `DeviceComponent` | `DeviceTabsComponent` |
| `DeviceProfilesTableConfigResolver` | DEVICE_PROFILE | `device-profile/` | `DeviceProfileComponent` | `DeviceProfileTabsComponent` |
| `AssetsTableConfigResolver` | ASSET | `asset/` | `AssetComponent` | `AssetTabsComponent` |
| `AssetProfilesTableConfigResolver` | ASSET_PROFILE | `asset-profile/` | `AssetProfileComponent` | `AssetProfileTabsComponent` |
| `EntityViewsTableConfigResolver` | ENTITY_VIEW | `entity-view/` | `EntityViewComponent` | `EntityViewTabsComponent` |
| `CustomersTableConfigResolver` | CUSTOMER | `customer/` | `CustomerComponent` | `CustomerTabsComponent` |
| `UsersTableConfigResolver` | USER | `user/` | `UserComponent` | `UserTabsComponent` |
| `DashboardsTableConfigResolver` | DASHBOARD | `dashboard/` | `DashboardFormComponent` | `DashboardTabsComponent` |
| `EdgesTableConfigResolver` | EDGE | `edge/` | `EdgeComponent` | `EdgeTabsComponent` |
| `RuleChainsTableConfigResolver` | RULE_CHAIN | `rulechain/` | `RuleChainComponent` | `RuleChainTabsComponent` |
| `OtaUpdateTableConfigResolve` | OTA_PACKAGE | `ota-update/` | `OtaUpdateComponent` | `OtaUpdateTabsComponent` |
| `CalculatedFieldsTableConfigResolver` | CALCULATED_FIELD | `calculated-fields/` (config in `home/components/calculated-fields/`) | `CalculatedFieldComponent` | `CalculatedFieldsTabsComponent` |
| `WidgetTypesTableConfigResolver` | WIDGETS_BUNDLE | `widget/` | `WidgetTypeComponent` | `WidgetTypeTabsComponent` |
| `WidgetsBundlesTableConfigResolver` | WIDGETS_BUNDLE | `widget/` | `WidgetsBundleComponent` | `WidgetsBundleTabsComponent` |
| `ResourcesLibraryTableConfigResolver` | TB_RESOURCE | `admin/` | `ResourcesLibraryComponent` | `ResourceLibraryTabsComponent` |
| `JsLibraryTableConfigResolver` | TB_RESOURCE | `admin/` | `JsResourceComponent` | `ResourceTabsComponent` |
| `QueuesTableConfigResolver` | QUEUE | `admin/queue/` | `QueueComponent` | — |
| `AiModelsTableConfigResolver` | AI_MODEL | `ai-model/` | — | — |
| `ClientsTableConfigResolver` | OAUTH2_CLIENT | `admin/oauth2/clients/` | `ClientComponent` | — |
| `InboxTableConfigResolver` | NOTIFICATION | `notification/inbox/` | — (dialog) | — |
| `SentTableConfigResolver` | NOTIFICATION_REQUEST | `notification/sent/` | — (dialog) | — |
| `RecipientTableConfigResolver` | NOTIFICATION_TARGET | `notification/recipient/` | — (dialog) | — |
| `TemplateTableConfigResolver` | NOTIFICATION_TEMPLATE | `notification/template/` | — (dialog) | — |
| `RuleTableConfigResolver` | NOTIFICATION_RULE | `notification/rule/` | — (dialog) | — |
| `MobileAppTableConfigResolver` | MOBILE_APP | `mobile/applications/` | `MobileAppComponent` | — |
| `MobileBundleTableConfigResolver` | MOBILE_BUNDLE | `mobile/bundes/` | — | — |
| `AlarmRulesTableConfigResolver` | CALCULATED_FIELD | `alarm/` (config in `home/components/alarm-rules/`) | — | — |
| `DomainTableConfigResolver` | DOMAIN | `admin/oauth2/domains/` | `DomainComponent` | — |
---
## 4. Shared Component Catalog
### `@shared/components/entity/`
| Selector | Purpose |
|----------|---------|
| `tb-entity-autocomplete` | Autocomplete input for selecting a single entity |
| `tb-entity-select` | Dropdown select for entity |
| `tb-entity-list` | Chip list for multiple entities |
| `tb-entity-list-select` | Dropdown with entity list selection |
| `tb-entity-key-autocomplete` | Autocomplete for entity attribute/telemetry keys |
| `tb-entity-keys-list` | Chip list for multiple entity keys |
| `tb-entity-type-select` | Dropdown for entity type enum |
| `tb-entity-type-list` | Chip list for multiple entity types |
| `tb-entity-subtype-select` | Dropdown for entity subtype (e.g., device type) |
| `tb-entity-subtype-list` | Chip list for multiple subtypes |
| `tb-entity-subtype-autocomplete` | Autocomplete for entity subtype |
| `tb-entity-gateway-select` | Dropdown for selecting a gateway device |
### `@shared/components/time/`
| Selector | Purpose |
|----------|---------|
| `tb-timewindow` | Timewindow selector (realtime/history) |
| `tb-timewindow-panel` | Expanded panel for timewindow config |
| `tb-timewindow-config-dialog` | Dialog for advanced timewindow settings |
| `tb-datetime` | Date-time picker input |
| `tb-datetime-period` | Date range picker |
| `tb-timezone` | Timezone display |
| `tb-timezone-select` | Dropdown for timezone selection |
| `tb-quick-time-interval` | Quick interval preset selector |
| `tb-timeinterval` | Duration input (number + unit) |
| `tb-datapoints-limit` | Numeric input for data point limits |
| `tb-aggregation-type-select` | Dropdown for aggregation type (AVG, SUM, etc.) |
| `tb-history-selector` | History navigation selector |
### `@shared/components/image/`
| Selector | Purpose |
|----------|---------|
| `tb-image-gallery` | Grid gallery of uploaded images |
| `tb-image-gallery-dialog` | Dialog wrapper for image gallery |
| `tb-image-input` | Single image upload input |
| `tb-gallery-image-input` | Image input that opens gallery for selection |
| `tb-multiple-gallery-image-input` | Multi-image input with gallery |
| `tb-upload-image-dialog` | Upload dialog |
| `tb-embed-image-dialog` | Embed external image dialog |
| `tb-scada-symbol-input` | SCADA symbol selector input |
### `@shared/components/dialog/`
| Selector | Purpose |
|----------|---------|
| `tb-confirm-dialog` | Yes/No confirmation dialog |
| `tb-alert-dialog` | Information alert dialog |
| `tb-error-alert-dialog` | Error message dialog |
| `tb-color-picker-dialog` | Color picker in a dialog |
| `tb-material-icons-dialog` | Material icon browser dialog |
| `tb-node-script-test-dialog` | Script testing dialog for rule nodes |
| `tb-object-edit-dialog` | JSON object editor dialog |
| `tb-todo-dialog` | Todo/checklist dialog |
### `@shared/components/` (other)
| Selector | Purpose |
|----------|---------|
| `tb-json-content` | JSON syntax-highlighted viewer |
| `tb-json-object-edit` | Editable JSON object component |
| `tb-json-object-view` | Read-only JSON object display |
| `tb-markdown` | Markdown editor/viewer |
| `tb-breadcrumb` | Breadcrumb navigation bar |
| `tb-icon` | Custom icon component |
| `tb-logo` | ThingsBoard logo |
| `tb-copy-button` | Copy-to-clipboard button |
| `tb-toggle-password` | Password visibility toggle |
| `tb-file-input` | File upload input |
| `tb-phone-input` | International phone input |
| `tb-unit-input` | Measurement unit input |
| `tb-value-input` | Generic value input with type selection |
| `tb-color-input` | Color picker input |
| `tb-color-picker` | Inline color picker |
| `tb-key-val-map` | Key-value pair map editor |
| `tb-nav-tree` | Tree navigation component |
| `tb-user-menu` | User profile dropdown menu |
| `tb-notification-bell` | Notification bell icon with badge |
| `tb-footer` | Page footer |
| `tb-scroll-grid` | Virtual-scroll grid |
| `tb-social-share-panel` | Social sharing buttons |
### Autocomplete / Select Components
| Selector | Purpose |
|----------|---------|
| `tb-dashboard-autocomplete` | Autocomplete for dashboard selection |
| `tb-dashboard-select` | Dropdown for dashboard |
| `tb-dashboard-state-autocomplete` | Autocomplete for dashboard states |
| `tb-country-autocomplete` | Country autocomplete |
| `tb-string-autocomplete` | Generic string autocomplete |
| `tb-message-type-autocomplete` | Rule engine message type autocomplete |
| `tb-queue-autocomplete` | Queue name autocomplete |
| `tb-relation-type-autocomplete` | Relation type autocomplete |
| `tb-resource-autocomplete` | Resource autocomplete |
| `tb-ota-package-autocomplete` | OTA package autocomplete |
| `tb-branch-autocomplete` | Git branch autocomplete |
| `tb-rule-chain-select` | Rule chain select dropdown |
| `tb-widgets-bundle-select` | Widgets bundle select dropdown |
| `tb-template-autocomplete` | Notification template autocomplete |
| `tb-material-icon-select` | Material icon picker |
| `tb-slack-conversation-autocomplete` | Slack conversation autocomplete |
### `@home/components/` (key components)
| Selector | Purpose |
|----------|---------|
| `tb-entities-table` | Main entity table (columns, sort, pagination, actions) |
| `tb-entity-details-panel` | Slide-out panel for entity details |
| `tb-entity-details-page` | Full-page entity details view |
| `tb-add-entity-dialog` | Generic add-entity dialog |
| `tb-entity-filter` | Entity data filter component |
| `tb-entity-chips` | Chip display for entity references |
| `tb-details-panel` | Base details panel (toolbar + edit/apply FABs) |
| `tb-alarm-table` | Alarm list table |
| `tb-alarm-details-dialog` | Alarm detail dialog with ack/clear |
| `tb-alarm-assignee` | Alarm assignee selector |
| `tb-alarm-comment` | Alarm comment thread |
| `tb-alarm-filter-config` | Alarm filter configuration |
| `tb-audit-log-table` | Audit log table |
| `tb-attribute-table` | Entity attribute key-value table |
| `tb-relation-table` | Entity relation table |
| `tb-event-table` | Entity event table |
| `tb-dashboard` | Dashboard renderer |
| `tb-dashboard-page` | Dashboard page (toolbar + dashboard) |
| `tb-dashboard-toolbar` | Dashboard edit/view toolbar |
| `tb-edit-widget` | Widget editor |
| `tb-router-tabs` | Tab navigation via router (used for section tabs) |
| `tb-device-credentials` | Device credentials form |
---
## 5. Angular Material DOM Patterns
**No `data-testid` attributes** — selectors must use Angular Material structure, `formControlName`, `matColumnDef`, labels, roles, and classes.
### Login Form
```html
<mat-card appearance="raised">
<mat-card-content>
<form class="tb-login-form" [formGroup]>
<mat-form-field appearance="outline">
<mat-label><!-- translated --></mat-label>
<input id="username-input" matInput type="email" formControlName="username">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label><!-- translated --></mat-label>
<input id="password-input" matInput type="password" formControlName="password">
</mat-form-field>
<button mat-flat-button type="submit"><!-- Sign In --></button>
</form>
</mat-card-content>
</mat-card>
```
Key selectors: `#username-input`, `#password-input`, `button[type="submit"]`.
### Main Layout
```html
<mat-sidenav-container>
<mat-sidenav id="sidenav" class="tb-site-sidenav">
<tb-side-menu/>
</mat-sidenav>
<mat-sidenav-content>
<mat-toolbar color="primary" class="tb-primary-toolbar">
<button id="main" mat-icon-button><!-- menu toggle --></button>
<tb-breadcrumb/>
<tb-notification-bell/>
<tb-user-menu/>
</mat-toolbar>
<router-outlet/>
</mat-sidenav-content>
</mat-sidenav-container>
```
Key selectors: `mat-sidenav#sidenav`, `button#main`, `tb-breadcrumb`, `tb-user-menu`.
### Entity Table
```html
<mat-drawer-container hasBackdrop="false">
<mat-drawer position="end" class="tb-details-drawer">
<tb-entity-details-panel/>
</mat-drawer>
<mat-drawer-content>
<div class="tb-entity-table">
<!-- Toolbar: title, add button, refresh, search -->
<mat-toolbar class="mat-mdc-table-toolbar">
<span class="tb-entity-table-title"><!-- title --></span>
<button mat-icon-button><!-- add --></button>
<button mat-icon-button><!-- refresh --></button>
<button mat-icon-button><!-- search toggle --></button>
</mat-toolbar>
<!-- Search toolbar (hidden by default) -->
<mat-toolbar class="mat-mdc-table-toolbar">
<mat-form-field>
<input id="searchInput" matInput [formControl]="textSearch">
</mat-form-field>
</mat-toolbar>
<!-- Selection toolbar (shown when rows selected) -->
<mat-toolbar class="mat-mdc-table-toolbar" color="primary">
<!-- group action buttons -->
</mat-toolbar>
<!-- Data table -->
<div class="table-container">
<table mat-table [matSort]>
<ng-container matColumnDef="select" sticky>
<mat-header-cell> <mat-checkbox/> </mat-header-cell>
<mat-cell> <mat-checkbox/> </mat-cell>
</ng-container>
<!-- Dynamic columns -->
<ng-container [matColumnDef]="column.key">
<mat-header-cell mat-sort-header><!-- header --></mat-header-cell>
<mat-cell><!-- value --></mat-cell>
</ng-container>
<!-- Actions column -->
<ng-container matColumnDef="actions" stickyEnd>
<mat-cell>
<button mat-icon-button><!-- per-row actions --></button>
</mat-cell>
</ng-container>
<mat-header-row sticky="true"/>
<mat-row/>
</table>
</div>
<mat-paginator/>
</div>
</mat-drawer-content>
</mat-drawer-container>
```
Key selectors: `table[mat-table]`, `mat-row`, `mat-cell`, `mat-paginator`, `[matColumnDef="select"]`, `[matColumnDef="actions"]`. Note: the search input uses Angular template ref `#searchInput` (not an HTML `id`); target it via `.tb-entity-table mat-toolbar input[matInput]` in the search toolbar context.
### Entity Details Page
```html
<mat-card appearance="outlined" class="settings-card">
<mat-toolbar class="details-toolbar">
<button mat-icon-button><!-- back --></button>
<div class="tb-details-title"><!-- entity name --></div>
<section class="tb-header-buttons">
<button mat-fab color="accent"><!-- apply (done icon) --></button>
<button mat-fab color="accent"><!-- edit/close toggle --></button>
</section>
</mat-toolbar>
<mat-card-content>
<mat-tab-group mat-stretch-tabs="false">
<mat-tab><!-- entity form injected here --></mat-tab>
<!-- additional tabs: attributes, relations, events, etc. -->
</mat-tab-group>
</mat-card-content>
</mat-card>
```
Key selectors: `mat-toolbar.details-toolbar`, `button[mat-fab][color="accent"]`, `mat-tab-group`, `mat-tab`.
### Details Panel (Slide-Out Drawer)
```html
<header>
<mat-toolbar class="details-toolbar" color="primary">
<span class="tb-details-title-text"><!-- entity name --></span>
<button mat-icon-button><!-- close --></button>
<section class="tb-header-buttons">
<button mat-fab color="accent"><!-- apply --></button>
<button mat-fab color="accent"><!-- edit/close --></button>
</section>
</mat-toolbar>
</header>
<div class="mat-content">
<mat-tab-group><!-- tabs --></mat-tab-group>
</div>
```
### Entity Detail Form (inside a tab)
```html
<div class="mat-padding">
<form [formGroup]="entityForm">
<fieldset [disabled]="isEntityReadonly">
<mat-form-field>
<mat-label><!-- translated label --></mat-label>
<input matInput formControlName="name">
<mat-error><!-- validation message --></mat-error>
</mat-form-field>
<mat-form-field>
<mat-label><!-- translated label --></mat-label>
<input matInput formControlName="label">
</mat-form-field>
<tb-entity-autocomplete formControlName="someEntityId"/>
<mat-slide-toggle formControlName="gateway"><!-- label --></mat-slide-toggle>
<mat-form-field>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
</fieldset>
</form>
</div>
```
Key selectors: `input[formControlName="name"]`, `mat-slide-toggle[formControlName="..."]`, `textarea[formControlName="description"]`.
### Dialog
```html
<form style="width: 750px;">
<mat-toolbar color="primary">
<h2 translate><!-- dialog title --></h2>
<span class="flex-1"></span>
<button mat-icon-button><!-- close --></button>
</mat-toolbar>
<mat-progress-bar/>
<div mat-dialog-content>
<!-- form content -->
</div>
<div mat-dialog-actions>
<button mat-button><!-- Cancel --></button>
<button mat-raised-button type="submit"><!-- Add/Save --></button>
</div>
</form>
```
Key selectors: `[mat-dialog-content]`, `[mat-dialog-actions]`, `button[type="submit"]`, `mat-toolbar[color="primary"] h2`.
### Common Material Selector Patterns
```
# Find a form field by its control name
input[formControlName="name"]
mat-select[formControlName="type"]
mat-slide-toggle[formControlName="gateway"]
textarea[formControlName="description"]
# Find table column content
[matColumnDef="name"] mat-cell
# Find buttons by tooltip
button[matTooltip="Search"]
# Find a specific tab
mat-tab-group mat-tab (by index or label text)
# Find menu items (after opening menu)
button[mat-menu-item]
# Find dialog actions
[mat-dialog-actions] button[mat-raised-button]
```
---
## 6. Form Structure Guide
All entity detail forms extend the abstract `EntityComponent<T>` directive (`@home/components/entity/entity.component.ts`).
**Key properties:**
- `entityForm: UntypedFormGroup` — the reactive form instance
- `entity: T` — current entity data (input)
- `isEdit: boolean` — whether form is in edit mode (input)
**Lifecycle:**
1. **`buildForm(entity: T): UntypedFormGroup`** — Called in constructor. Creates the FormGroup with controls, validators, and initial values. Subscribe to valueChanges here for cascading updates.
2. **`updateForm(entity: T): void`** — Called when entity input changes. Uses `entityForm.patchValue({...}, {emitEvent: false})` to populate form without triggering change events.
3. **`updateFormState(): void`** — Called when `isEdit` changes. Calls `entityForm.enable()` or `entityForm.disable()` with `{emitEvent: false}`.
4. **`entityFormValue(): any`** — Returns `entityForm.getRawValue()` (reads disabled fields too), passed through `prepareFormValue()` (default: `deepTrim`).
**Read-only pattern:** Templates use `<fieldset [disabled]="isEntityReadonly">` to visually disable all inputs. The form group is also programmatically disabled via `entityForm.disable()`.
**Saving:** Merges `{...this.entity, ...this.entityFormValue()}` and calls the HTTP service.
**Common validators:** `Validators.required`, `Validators.maxLength(255)`, custom `validateEmail`.
**Nested FormGroups** — `additionalInfo` is almost always a nested group:
```typescript
additionalInfo: this.fb.group({
description: [''],
gateway: [false],
// ...
})
```
---
## 7. HTTP Services Reference
All services are in `src/app/core/http/`.
| Entity / Area | Service File | Class |
|---------------|-------------|-------|
| Device | `device.service.ts` | `DeviceService` |
| Asset | `asset.service.ts` | `AssetService` |
| Device Profile | `device-profile.service.ts` | `DeviceProfileService` |
| Asset Profile | `asset-profile.service.ts` | `AssetProfileService` |
| Entity View | `entity-view.service.ts` | `EntityViewService` |
| Customer | `customer.service.ts` | `CustomerService` |
| User | `user.service.ts` | `UserService` |
| Tenant | `tenant.service.ts` | `TenantService` |
| Tenant Profile | `tenant-profile.service.ts` | `TenantProfileService` |
| Dashboard | `dashboard.service.ts` | `DashboardService` |
| Rule Chain | `rule-chain.service.ts` | `RuleChainService` |
| Edge | `edge.service.ts` | `EdgeService` |
| Alarm | `alarm.service.ts` | `AlarmService` |
| Alarm Comment | `alarm-comment.service.ts` | `AlarmCommentService` |
| OTA Package | `ota-package.service.ts` | `OtaPackageService` |
| Calculated Fields | `calculated-fields.service.ts` | `CalculatedFieldsService` |
| Widget | `widget.service.ts` | `WidgetService` |
| Resource | `resource.service.ts` | `ResourceService` |
| Queue | `queue.service.ts` | `QueueService` |
| AI Model | `ai-model.service.ts` | `AiModelService` |
| Notification | `notification.service.ts` | `NotificationService` |
| OAuth2 | `oauth2.service.ts` | `OAuth2Service` |
| Domain | `domain.service.ts` | `DomainService` |
| Mobile App | `mobile-app.service.ts` | `MobileAppService` |
| Attribute | `attribute.service.ts` | `AttributeService` |
| Entity Relation | `entity-relation.service.ts` | `EntityRelationService` |
| Entity (generic) | `entity.service.ts` | `EntityService` |
| Event | `event.service.ts` | `EventService` |
| Audit Log | `audit-log.service.ts` | `AuditLogService` |
| Admin Settings | `admin.service.ts` | `AdminService` |
| Version Control | `entities-version-control.service.ts` | `EntitiesVersionControlService` |
| 2FA | `two-factor-authentication.service.ts` | `TwoFactorAuthenticationService` |
| User Settings | `user-settings.service.ts` | `UserSettingsService` |
| API Key | `api-key.service.ts` | `ApiKeyService` |
| Image | `image.service.ts` | `ImageService` |
| Trendz Settings | `trendz-settings.service.ts` | `TrendzSettingsService` |
| Usage Info | `usage-info.service.ts` | `UsageInfoService` |
| Component Descriptor | `component-descriptor.service.ts` | `ComponentDescriptorService` |
| GitHub | `git-hub.service.ts` | `GitHubService` |
| Mobile Application | `mobile-application.service.ts` | `MobileApplicationService` |
| UI Settings | `ui-settings.service.ts` | `UiSettingsService` |

6
ui-ngx/yarn.lock

@ -6033,9 +6033,9 @@ flat@^5.0.2:
integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
flatted@^3.2.9:
version "3.4.1"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.1.tgz#84ccd9579e76e9cc0d246c11d8be0beb019143e6"
integrity sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==
version "3.4.2"
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
"flot.curvedlines@https://github.com/MichaelZinsmaier/CurvedLines.git#master":
version "1.1.1"

Loading…
Cancel
Save