93 changed files with 18464 additions and 3199 deletions
@ -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()); |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
|
|||
} |
|||
File diff suppressed because it is too large
@ -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); |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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; |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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); |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
|
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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)); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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()) |
|||
); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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())); |
|||
} |
|||
|
|||
} |
|||
@ -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()) |
|||
); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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)); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
@ -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(); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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()); |
|||
} |
|||
|
|||
} |
|||
@ -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) { |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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) { |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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/")); |
|||
} |
|||
|
|||
} |
|||
@ -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()) |
|||
); |
|||
} |
|||
|
|||
} |
|||
@ -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) |
|||
); |
|||
} |
|||
|
|||
} |
|||
@ -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) {} |
|||
@ -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); |
|||
} |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
File diff suppressed because it is too large
@ -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` | |
|||
Loading…
Reference in new issue