Browse Source

Merge branch 'rc' into update-install-instructions

pull/13260/head
yevhenii_zahrebelnyi 1 year ago
committed by GitHub
parent
commit
a6cc03fb64
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
  2. 9
      application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
  3. 37
      application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java
  4. 47
      application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java
  5. 8
      application/src/main/java/org/thingsboard/server/controller/BaseController.java
  6. 6
      application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java
  7. 2
      application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java
  8. 3
      application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java
  9. 1
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  10. 24
      application/src/main/resources/thingsboard.yml
  11. 2
      application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java
  12. 12
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java
  13. 15
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
  14. 14
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java
  15. 20
      netty-mqtt/pom.xml
  16. 24
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java
  17. 30
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java
  18. 2
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java
  19. 20
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java
  20. 82
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java
  21. 3
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java
  22. 134
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java
  23. 119
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java
  24. 85
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java
  25. 17
      netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java
  26. 4
      netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java
  27. 101
      netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java
  28. 210
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java
  29. 63
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java
  30. 202
      netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java
  31. 151
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java
  32. 84
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java
  33. 141
      netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java
  34. 3
      netty-mqtt/src/test/resources/junit-platform.properties
  35. 6
      pom.xml
  36. 26
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java
  37. 5
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java
  38. 8
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java
  39. 18
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java
  40. 9
      ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts
  41. 10
      ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts
  42. 10
      ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts
  43. 13
      ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts
  44. 9
      ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts
  45. 2
      ui-ngx/src/app/shared/models/constants.ts
  46. 34
      ui-ngx/src/assets/locale/locale.constant-nl_BE.json

5
application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java

@ -32,6 +32,7 @@ import org.springframework.stereotype.Component;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.rule.engine.api.notification.SlackService;
@ -639,6 +640,10 @@ public class ActorSystemContext {
@Getter
private long cfCalculationResultTimeout;
@Autowired
@Getter
private MqttClientSettings mqttClientSettings;
@Getter
@Setter
private TbActorSystem actorSystem;

9
application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java

@ -23,14 +23,15 @@ import org.bouncycastle.util.Arrays;
import org.thingsboard.common.util.DebugModeUtil;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.common.util.ListeningExecutor;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.rule.engine.api.RuleEngineAlarmService;
import org.thingsboard.rule.engine.api.RuleEngineApiUsageStateService;
import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache;
import org.thingsboard.rule.engine.api.RuleEngineCalculatedFieldQueueService;
import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.RuleEngineRpcService;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.ScriptEngine;
@ -1010,13 +1011,17 @@ public class DefaultTbContext implements TbContext {
return mainCtx.getAuditLogService();
}
@Override
public MqttClientSettings getMqttClientSettings() {
return mainCtx.getMqttClientSettings();
}
private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) {
TbMsgMetaData metaData = new TbMsgMetaData();
metaData.putValue("ruleNodeId", ruleNodeId.toString());
return metaData;
}
@Override
public void schedule(Runnable runnable, long delay, TimeUnit timeUnit) {
mainCtx.getScheduler().schedule(runnable, delay, timeUnit);

37
application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientRetransmissionSettingsComponent.java

@ -0,0 +1,37 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.config.mqtt;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
import org.springframework.validation.annotation.Validated;
@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "mqtt.client.retransmission")
public class MqttClientRetransmissionSettingsComponent {
@PositiveOrZero
private int maxAttempts;
@PositiveOrZero
private long initialDelayMillis;
@PositiveOrZero
private double jitterFactor;
}

47
application/src/main/java/org/thingsboard/server/config/mqtt/MqttClientSettingsComponent.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.config.mqtt;
import lombok.EqualsAndHashCode;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
import org.springframework.context.annotation.Configuration;
import org.thingsboard.rule.engine.api.MqttClientSettings;
@ToString
@EqualsAndHashCode
@Configuration
@RequiredArgsConstructor
public class MqttClientSettingsComponent implements MqttClientSettings {
private final MqttClientRetransmissionSettingsComponent retransmissionSettingsComponent;
@Override
public int getRetransmissionMaxAttempts() {
return retransmissionSettingsComponent.getMaxAttempts();
}
@Override
public long getRetransmissionInitialDelayMillis() {
return retransmissionSettingsComponent.getInitialDelayMillis();
}
@Override
public double getRetransmissionJitterFactor() {
return retransmissionSettingsComponent.getJitterFactor();
}
}

8
application/src/main/java/org/thingsboard/server/controller/BaseController.java

@ -964,8 +964,12 @@ public abstract class BaseController {
}
}
protected CalculatedField checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
return checkEntityId(calculatedFieldId, calculatedFieldService::findById, operation);
private void checkCalculatedFieldId(CalculatedFieldId calculatedFieldId, Operation operation) throws ThingsboardException {
validateId(calculatedFieldId, "Invalid entity id");
SecurityUser user = getCurrentUser();
CalculatedField cf = calculatedFieldService.findById(user.getTenantId(), calculatedFieldId);
checkNotNull(cf, calculatedFieldId.getEntityType().getNormalName() + " with id [" + calculatedFieldId + "] is not found");
checkEntityId(cf.getEntityId(), operation);
}
protected HomeDashboardInfo getHomeDashboardInfo(SecurityUser securityUser, JsonNode additionalInfo) {

6
application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java

@ -59,7 +59,6 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngi
import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.permission.Operation;
import org.thingsboard.server.service.security.permission.Resource;
import java.util.ArrayList;
import java.util.Collections;
@ -136,7 +135,6 @@ public class CalculatedFieldController extends BaseController {
public CalculatedField saveCalculatedField(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the calculated field.")
@RequestBody CalculatedField calculatedField) throws Exception {
calculatedField.setTenantId(getTenantId());
checkEntity(calculatedField.getId(), calculatedField, Resource.CALCULATED_FIELD);
checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
checkReferencedEntities(calculatedField.getConfiguration(), getCurrentUser());
return tbCalculatedFieldService.save(calculatedField, getCurrentUser());
@ -186,7 +184,7 @@ public class CalculatedFieldController extends BaseController {
public void deleteCalculatedField(@PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws Exception {
checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.DELETE);
CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser());
checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD);
tbCalculatedFieldService.delete(calculatedField, getCurrentUser());
}
@ -200,7 +198,7 @@ public class CalculatedFieldController extends BaseController {
public JsonNode getLatestCalculatedFieldDebugEvent(@Parameter @PathVariable(CALCULATED_FIELD_ID) String strCalculatedFieldId) throws ThingsboardException {
checkParameter(CALCULATED_FIELD_ID, strCalculatedFieldId);
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strCalculatedFieldId));
CalculatedField calculatedField = checkCalculatedFieldId(calculatedFieldId, Operation.READ);
CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser());
checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD);
TenantId tenantId = getCurrentUser().getTenantId();
return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1))

2
application/src/main/java/org/thingsboard/server/service/entitiy/ota/DefaultTbOtaPackageService.java

@ -86,7 +86,7 @@ public class DefaultTbOtaPackageService extends AbstractTbEntityService implemen
otaPackage.setContentType(contentType);
otaPackage.setData(ByteBuffer.wrap(data));
otaPackage.setDataSize((long) data.length);
OtaPackageInfo savedOtaPackage = otaPackageService.saveOtaPackage(otaPackage);
OtaPackageInfo savedOtaPackage = new OtaPackageInfo(otaPackageService.saveOtaPackage(otaPackage));
logEntityActionService.logEntityAction(tenantId, savedOtaPackage.getId(), savedOtaPackage, null, actionType, user);
return savedOtaPackage;
} catch (Exception e) {

3
application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java

@ -50,8 +50,7 @@ public enum Resource {
VERSION_CONTROL,
NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE,
EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE),
MOBILE_APP_SETTINGS,
CALCULATED_FIELD(EntityType.CALCULATED_FIELD);
MOBILE_APP_SETTINGS;
private final Set<EntityType> entityTypes;

1
application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java

@ -55,7 +55,6 @@ public class TenantAdminPermissions extends AbstractPermissions {
put(Resource.OAUTH2_CONFIGURATION_TEMPLATE, new PermissionChecker.GenericPermissionChecker(Operation.READ));
put(Resource.MOBILE_APP, tenantEntityPermissionChecker);
put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker);
put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker);
}
public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() {

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

@ -1952,3 +1952,27 @@ mobileApp:
googlePlayLink: "${TB_MOBILE_APP_GOOGLE_PLAY_LINK:https://play.google.com/store/apps/details?id=org.thingsboard.demo.app}"
# Link to App Store for Thingsboard Live mobile application
appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}"
mqtt:
# MQTT client configuration parameters
client:
# Parameters that control the retransmission mechanism.
# This mechanism only applies to the handling of MQTT Publish, Subscribe, Unsubscribe and Pubrel messages.
# With the updated default settings:
# - After sending the message, wait approximately 5000 ms (± jitter) for the 1st attempt.
# - The 2nd attempt will occur after roughly 5000 * 2 = 10,000 ms (± jitter).
# - The 3rd attempt will occur after roughly 5000 * 4 = 20,000 ms (± jitter).
# - The 4th "attempt" will not actually perform a retransmission.
# Instead, the system will detect that the maximum number of attempts has been reached and drop the pending message.
retransmission:
# Maximum number of retransmission attempts allowed.
# If the attempt count exceeds this value, retransmissions will stop and the pending message will be dropped.
max_attempts: "${TB_MQTT_CLIENT_RETRANSMISSION_MAX_ATTEMPTS:3}"
# Base delay (in milliseconds) before the first retransmission attempt, measured from the moment the message is sent.
# Subsequent delays are calculated using exponential backoff.
# This base delay is also used as the reference value for applying jitter.
initial_delay_millis: "${TB_MQTT_CLIENT_RETRANSMISSION_INITIAL_DELAY_MILLIS:5000}"
# Jitter factor applied to the calculated retransmission delay.
# The actual delay is randomized within a range defined by multiplying the base delay by a factor between (1 - jitter_factor) and (1 + jitter_factor).
# For example, a jitter_factor of 0.15 means the actual delay may vary by up to ±15% of the base delay.
jitter_factor: "${TB_MQTT_CLIENT_RETRANSMISSION_JITTER_FACTOR:0.15}"

2
application/src/test/java/org/thingsboard/server/controller/OtaPackageControllerTest.java

@ -216,7 +216,7 @@ public class OtaPackageControllerTest extends AbstractControllerTest {
Assert.assertEquals(CHECKSUM_ALGORITHM, savedFirmware.getChecksumAlgorithm().name());
Assert.assertEquals(CHECKSUM, savedFirmware.getChecksum());
testNotifyEntityAllOneTime(savedFirmware, savedFirmware.getId(), savedFirmware.getId(),
testNotifyEntityAllOneTime(new OtaPackageInfo(savedFirmware), savedFirmware.getId(), savedFirmware.getId(),
savedTenant.getId(), tenantAdmin.getCustomerId(), tenantAdmin.getId(), tenantAdmin.getEmail(),
ActionType.UPDATED);
}

12
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java

@ -55,8 +55,8 @@ public class ContainerTestSuite {
private static final String TB_JS_EXECUTOR_LOG_REGEXP = ".*template started.*";
private static final Duration CONTAINER_STARTUP_TIMEOUT = Duration.ofSeconds(400);
private DockerComposeContainer<?> testContainer;
private ThingsBoardDbInstaller installTb;
private DockerComposeContainer<?> testContainer;
private ThingsBoardDbInstaller installTb;
private boolean isActive;
private static ContainerTestSuite containerTestSuite;
@ -194,7 +194,7 @@ public class ContainerTestSuite {
setActive(true);
} catch (Exception e) {
log.error("Failed to create test container", e);
fail("Failed to create test container");
fail("Failed to create test container", e);
}
}
@ -263,7 +263,7 @@ public class ContainerTestSuite {
log.info("Trying to delete temp dir {}", targetDir);
FileUtils.deleteDirectory(new File(targetDir));
} catch (IOException e) {
log.error("Can't delete temp directory " + targetDir, e);
log.error("Can't delete temp directory {}", targetDir, e);
}
}
@ -286,8 +286,8 @@ public class ContainerTestSuite {
FileUtils.writeStringToFile(file, outputContent, StandardCharsets.UTF_8);
assertThat(FileUtils.readFileToString(file, StandardCharsets.UTF_8), is(outputContent));
} catch (IOException e) {
log.error("failed to update file " + sourceFilename, e);
fail("failed to update file");
log.error("failed to update file {}", sourceFilename, e);
fail("failed to update file", e);
}
}

15
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java

@ -42,7 +42,6 @@ import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientCallback;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttHandler;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.DeviceProfileProvisionType;
@ -82,7 +81,6 @@ import java.util.concurrent.TimeoutException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.fail;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevicePrototype;
@ -301,7 +299,7 @@ public class MqttClientTest extends AbstractContainerTest {
assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}");
Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
JsonObject clientResponse = new JsonObject();
clientResponse.addProperty("response", "someResponse");
// Send a response to the server's RPC request
@ -340,7 +338,7 @@ public class MqttClientTest extends AbstractContainerTest {
assertThat(Objects.requireNonNull(requestFromServer).getMessage()).isEqualTo("{\"method\":\"getValue\",\"params\":true}");
Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
int requestId = Integer.parseInt(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length()));
JsonObject clientResponse = new JsonObject();
clientResponse.addProperty("response", "someResponse");
// Send a response to the server's RPC request
@ -520,13 +518,13 @@ public class MqttClientTest extends AbstractContainerTest {
mqttClient.on("/provision/response", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS);
TimeUnit.SECONDS.sleep(2 * timeoutMultiplier);
assertThat(subAckResult[0]).isNotNull();
assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1.equals(subAckResult[0]));
assertThat(MqttReasonCodes.SubAck.GRANTED_QOS_1).isEqualTo(subAckResult[0]);
subAckResult[0] = null;
mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE).get(3 * timeoutMultiplier, TimeUnit.SECONDS);
TimeUnit.SECONDS.sleep(2 * timeoutMultiplier);
assertThat(subAckResult[0]).isNotNull();
assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID.equals(subAckResult[0]));
assertThat(MqttReasonCodes.SubAck.TOPIC_FILTER_INVALID).isEqualTo(subAckResult[0]);
testRestClient.deleteDeviceIfExists(device.getId());
updateDeviceProfileWithProvisioningStrategy(deviceProfile, DeviceProfileProvisionType.DISABLED);
@ -596,7 +594,7 @@ public class MqttClientTest extends AbstractContainerTest {
.await()
.alias("Check device disconnect.")
.atMost(TIMEOUT*timeoutMultiplier, TimeUnit.SECONDS)
.until(() -> returnCodeByteValue.size() > 0);
.until(() -> !returnCodeByteValue.isEmpty());
assertThat(returnCodeByteValueSecondClient).isEmpty();
assertThat(returnCodeByteValue).isNotEmpty();
@ -663,7 +661,7 @@ public class MqttClientTest extends AbstractContainerTest {
.stream()
.filter(RuleChain::isRoot)
.findFirst();
if (!defaultRuleChain.isPresent()) {
if (defaultRuleChain.isEmpty()) {
fail("Root rule chain wasn't found");
}
return defaultRuleChain.get().getId();
@ -717,6 +715,7 @@ public class MqttClientTest extends AbstractContainerTest {
clientConfig.setClientId("MQTT client from test");
clientConfig.setUsername(username);
clientConfig.setProtocolVersion(mqttVersion);
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(3, 5000L, 0.15d)); // same as defaults in thingsboard.yml as of time of this writing
MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor);
if (connect) {
mqttClient.connect(TRANSPORT_HOST, TRANSPORT_PORT).get();

14
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttGatewayClientTest.java

@ -39,7 +39,6 @@ import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttHandler;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.id.DeviceId;
@ -65,7 +64,6 @@ import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.thingsboard.server.common.data.DataConstants.DEVICE;
import static org.thingsboard.server.common.data.DataConstants.SHARED_SCOPE;
import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultGatewayPrototype;
@ -76,7 +74,6 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
private MqttClient mqttClient;
private Device createdDevice;
private MqttMessageListener listener;
private JsonParser jsonParser = new JsonParser();
AbstractListeningExecutor handlerExecutor;
@ -100,7 +97,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
}
@AfterMethod
public void removeGateway() {
public void removeGateway() {
testRestClient.deleteDeviceIfExists(this.gatewayDevice.getId());
testRestClient.deleteDeviceIfExists(this.createdDevice.getId());
this.listener = null;
@ -197,7 +194,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
JsonObject responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
JsonObject responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("value")).isTrue();
assertThat(responseData.get("value").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -213,7 +210,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get();
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("values")).isTrue();
assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -231,7 +228,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.on("v1/gateway/attributes/response", listener, MqttQoS.AT_LEAST_ONCE).get();
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(requestData.toString().getBytes())).get();
event = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
responseData = jsonParser.parse(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
responseData = JsonParser.parseString(Objects.requireNonNull(event).getMessage()).getAsJsonObject();
assertThat(responseData.has("values")).isTrue();
assertThat(responseData.get("values").getAsJsonObject().get("attr1").getAsString()).isEqualTo(sharedAttributes.get("attr1").getAsString());
@ -390,7 +387,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
mqttClient.publish("v1/gateway/attributes/request", Unpooled.wrappedBuffer(gatewayAttributesRequest.toString().getBytes())).get();
MqttEvent clientAttributeEvent = listener.getEvents().poll(10 * timeoutMultiplier, TimeUnit.SECONDS);
assertThat(clientAttributeEvent).isNotNull();
JsonObject responseMessage = new JsonParser().parse(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject();
JsonObject responseMessage = JsonParser.parseString(Objects.requireNonNull(clientAttributeEvent).getMessage()).getAsJsonObject();
assertThat(responseMessage.get("id").getAsInt()).isEqualTo(messageId);
assertThat(responseMessage.get("device").getAsString()).isEqualTo(createdDevice.getName());
@ -427,6 +424,7 @@ public class MqttGatewayClientTest extends AbstractContainerTest {
clientConfig.setOwnerId(getOwnerId());
clientConfig.setClientId("MQTT client from test");
clientConfig.setUsername(deviceCredentials.getCredentialsId());
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(3, 5000L, 0.15d)); // same as defaults in thingsboard.yml as of time of this writing
MqttClient mqttClient = MqttClient.create(clientConfig, listener, handlerExecutor);
mqttClient.connect("localhost", 1883).get();
return mqttClient;

20
netty-mqtt/pom.xml

@ -87,6 +87,26 @@
<artifactId>awaitility</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>software.xdev</groupId>
<artifactId>testcontainers-junit4-mock</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>hivemq</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

24
netty-mqtt/src/main/java/org/thingsboard/mqtt/MaxRetransmissionsReachedException.java

@ -0,0 +1,24 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt;
public class MaxRetransmissionsReachedException extends RuntimeException {
public MaxRetransmissionsReachedException(String message) {
super(message);
}
}

30
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttChannelHandler.java

@ -57,7 +57,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) throws Exception {
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
if (msg.decoderResult().isSuccess()) {
switch (msg.fixedHeader().messageType()) {
case CONNACK:
@ -120,6 +120,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
this.client.getClientConfig().getUsername(),
this.client.getClientConfig().getPassword() != null ? this.client.getClientConfig().getPassword().getBytes(CharsetUtil.UTF_8) : null
);
log.debug("{} Sending CONNECT", client.getClientConfig().getOwnerId());
ctx.channel().writeAndFlush(new MqttConnectMessage(fixedHeader, variableHeader, payload));
}
@ -173,6 +174,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handleConack(Channel channel, MqttConnAckMessage message) {
log.debug("{} Handling CONNACK", client.getClientConfig().getOwnerId());
switch (message.variableHeader().connectReturnCode()) {
case CONNECTION_ACCEPTED:
this.connectFuture.setSuccess(new MqttConnectResult(true, MqttConnectReturnCode.CONNECTION_ACCEPTED, channel.closeFuture()));
@ -219,9 +221,9 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
pendingSubscription.onSubackReceived();
for (MqttPendingSubscription.MqttPendingHandler handler : pendingSubscription.getHandlers()) {
MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.getHandler(), handler.isOnce());
MqttSubscription subscription = new MqttSubscription(pendingSubscription.getTopic(), handler.handler(), handler.once());
this.client.getSubscriptions().put(pendingSubscription.getTopic(), subscription);
this.client.getHandlerToSubscription().put(handler.getHandler(), subscription);
this.client.getHandlerToSubscription().put(handler.handler(), subscription);
}
this.client.getPendingSubscribeTopics().remove(pendingSubscription.getTopic());
@ -282,17 +284,16 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handlePuback(MqttPubAckMessage message) {
MqttPendingPublish pendingPublish = this.client.getPendingPublishes().get(message.variableHeader().messageId());
if (pendingPublish == null) {
return;
}
pendingPublish.getFuture().setSuccess(null);
pendingPublish.onPubackReceived();
this.client.getPendingPublishes().remove(message.variableHeader().messageId());
pendingPublish.getPayload().release();
if (this.client.getCallback() != null) {
this.client.getCallback().onPubAck(message);
}
log.trace("{} Handling PUBACK", client.getClientConfig().getOwnerId());
client.getPendingPublishes().computeIfPresent(message.variableHeader().messageId(), (__, pendingPublish) -> {
pendingPublish.getFuture().setSuccess(null);
pendingPublish.onPubackReceived();
pendingPublish.getPayload().release();
if (client.getCallback() != null) {
client.getCallback().onPubAck(message);
}
return null;
});
}
private void handlePubrec(Channel channel, MqttMessage message) {
@ -335,6 +336,7 @@ final class MqttChannelHandler extends SimpleChannelInboundHandler<MqttMessage>
}
private void handleDisconnect(MqttMessage message) {
log.debug("{} Handling DISCONNECT", client.getClientConfig().getOwnerId());
if (this.client.getCallback() != null) {
this.client.getCallback().onDisconnect(message);
}

2
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClient.java

@ -184,7 +184,7 @@ public interface MqttClient {
* @param config The config object to use while looking for settings
* @param defaultHandler The handler for incoming messages that do not match any topic subscriptions
*/
static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor){
static MqttClient create(MqttClientConfig config, MqttHandler defaultHandler, ListeningExecutor handlerExecutor) {
return new MqttClientImpl(config, defaultHandler, handlerExecutor);
}

20
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientConfig.java

@ -47,6 +47,26 @@ public final class MqttClientConfig {
private long reconnectDelay = 1L;
private int maxBytesInMessage = 8092;
@Getter
@Setter
private RetransmissionConfig retransmissionConfig;
public record RetransmissionConfig(int maxAttempts, long initialDelayMillis, double jitterFactor) {
public RetransmissionConfig {
if (maxAttempts < 0) {
throw new IllegalArgumentException("Max retransmission attempts (maxAttempts) must be zero or greater, but was " + maxAttempts);
}
if (initialDelayMillis < 0) {
throw new IllegalArgumentException("Initial retransmission delay (initialDelayMillis) must be zero or greater, but was " + initialDelayMillis);
}
if (jitterFactor < 0) {
throw new IllegalArgumentException("Jitter factor (jitterFactor) must be zero or greater, but was " + jitterFactor);
}
}
}
public MqttClientConfig() {
this(null);
}

82
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttClientImpl.java

@ -17,6 +17,7 @@ package org.thingsboard.mqtt;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Sets;
import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
@ -384,8 +385,33 @@ final class MqttClientImpl implements MqttClient {
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PUBLISH, false, qos, retain, 0);
MqttPublishVariableHeader variableHeader = new MqttPublishVariableHeader(topic, getNewMessageId().messageId());
MqttPublishMessage message = new MqttPublishMessage(fixedHeader, variableHeader, payload);
MqttPendingPublish pendingPublish = new MqttPendingPublish(variableHeader.packetId(), future,
payload.retain(), message, qos, () -> !pendingPublishes.containsKey(variableHeader.packetId()));
final var pendingPublish = MqttPendingPublish.builder()
.messageId(variableHeader.packetId())
.future(future)
.payload(payload.retain())
.message(message)
.qos(qos)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingPublishes.containsKey(variableHeader.packetId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingPublishes.computeIfPresent(variableHeader.packetId(), (__, pendingPublish) -> {
var message = "Unable to deliver publish message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.packetId());
pendingPublish.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
pendingPublish.getPayload().release();
return null;
});
}
}).build();
this.pendingPublishes.put(pendingPublish.getMessageId(), pendingPublish);
ChannelFuture channelFuture = this.sendAndFlushPacket(message);
@ -499,9 +525,30 @@ final class MqttClientImpl implements MqttClient {
MqttSubscribePayload payload = new MqttSubscribePayload(Collections.singletonList(subscription));
MqttSubscribeMessage message = new MqttSubscribeMessage(fixedHeader, variableHeader, payload);
final MqttPendingSubscription pendingSubscription = new MqttPendingSubscription(future, topic, message,
() -> !pendingSubscriptions.containsKey(variableHeader.messageId()));
pendingSubscription.addHandler(handler, once);
final var pendingSubscription = MqttPendingSubscription.builder()
.future(future)
.topic(topic)
.handlers(Sets.newHashSet(new MqttPendingSubscription.MqttPendingHandler(handler, once)))
.subscribeMessage(message)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingSubscriptions.containsKey(variableHeader.messageId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingSubscriptions.computeIfPresent(variableHeader.messageId(), (__, pendingSubscription) -> {
var message = "Unable to deliver subscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId());
pendingSubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
return null;
});
}
}).build();
this.pendingSubscriptions.put(variableHeader.messageId(), pendingSubscription);
this.pendingSubscribeTopics.add(topic);
pendingSubscription.setSent(this.sendAndFlushPacket(message) != null); //If not sent, we will send it when the connection is opened
@ -518,8 +565,29 @@ final class MqttClientImpl implements MqttClient {
MqttUnsubscribePayload payload = new MqttUnsubscribePayload(Collections.singletonList(topic));
MqttUnsubscribeMessage message = new MqttUnsubscribeMessage(fixedHeader, variableHeader, payload);
MqttPendingUnsubscription pendingUnsubscription = new MqttPendingUnsubscription(promise, topic, message,
() -> !pendingServerUnsubscribes.containsKey(variableHeader.messageId()));
final var pendingUnsubscription = MqttPendingUnsubscription.builder()
.future(promise)
.topic(topic)
.unsubscribeMessage(message)
.ownerId(clientConfig.getOwnerId())
.retransmissionConfig(clientConfig.getRetransmissionConfig())
.pendingOperation(new PendingOperation() {
@Override
public boolean isCancelled() {
return !pendingServerUnsubscribes.containsKey(variableHeader.messageId());
}
@Override
public void onMaxRetransmissionAttemptsReached() {
pendingServerUnsubscribes.computeIfPresent(variableHeader.messageId(), (__, pendingUnsubscription) -> {
var message = "Unable to deliver unsubscribe message due to max retransmission attempts (%s) being reached for client '%s' on topic '%s' (message ID: %d)"
.formatted(clientConfig.getRetransmissionConfig().maxAttempts(), clientConfig.getClientId(), topic, variableHeader.messageId());
pendingUnsubscription.getFuture().tryFailure(new MaxRetransmissionsReachedException(message));
return null;
});
}
}).build();
this.pendingServerUnsubscribes.put(variableHeader.messageId(), pendingUnsubscription);
pendingUnsubscription.startRetransmissionTimer(this.eventLoop.next(), this::sendAndFlushPacket);

3
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttConnectResult.java

@ -17,7 +17,9 @@ package org.thingsboard.mqtt;
import io.netty.channel.ChannelFuture;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import lombok.ToString;
@ToString
@SuppressWarnings({"WeakerAccess", "unused"})
public final class MqttConnectResult {
@ -42,4 +44,5 @@ public final class MqttConnectResult {
public ChannelFuture getCloseFuture() {
return closeFuture;
}
}

134
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingPublish.java

@ -21,9 +21,13 @@ import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import java.util.function.Consumer;
@Getter(AccessLevel.PACKAGE)
final class MqttPendingPublish {
private final int messageId;
@ -32,80 +36,126 @@ final class MqttPendingPublish {
private final MqttPublishMessage message;
private final MqttQoS qos;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttPublishMessage> publishRetransmissionHandler;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttMessage> pubrelRetransmissionHandler;
@Setter(AccessLevel.PACKAGE)
private boolean sent = false;
MqttPendingPublish(int messageId, Promise<Void> future, ByteBuf payload, MqttPublishMessage message, MqttQoS qos, PendingOperation operation) {
private MqttPendingPublish(
int messageId,
Promise<Void> future,
ByteBuf payload,
MqttPublishMessage message,
MqttQoS qos,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation pendingOperation
) {
this.messageId = messageId;
this.future = future;
this.payload = payload;
this.message = message;
this.qos = qos;
this.publishRetransmissionHandler = new RetransmissionHandler<>(operation);
this.publishRetransmissionHandler.setOriginalMessage(message);
this.pubrelRetransmissionHandler = new RetransmissionHandler<>(operation);
}
int getMessageId() {
return messageId;
}
Promise<Void> getFuture() {
return future;
}
ByteBuf getPayload() {
return payload;
}
boolean isSent() {
return sent;
}
void setSent(boolean sent) {
this.sent = sent;
}
MqttPublishMessage getMessage() {
return message;
}
MqttQoS getQos() {
return qos;
publishRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId);
publishRetransmissionHandler.setOriginalMessage(message);
pubrelRetransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, pendingOperation, ownerId);
}
void startPublishRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.publishRetransmissionHandler.setHandle(((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), this.payload.retain()))));
this.publishRetransmissionHandler.start(eventLoop);
publishRetransmissionHandler.setHandler(((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttPublishMessage(fixedHeader, originalMessage.variableHeader(), payload.retain()))));
publishRetransmissionHandler.start(eventLoop);
}
void onPubackReceived() {
this.publishRetransmissionHandler.stop();
publishRetransmissionHandler.stop();
}
void setPubrelMessage(MqttMessage pubrelMessage) {
this.pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage);
pubrelRetransmissionHandler.setOriginalMessage(pubrelMessage);
}
void startPubrelRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.pubrelRetransmissionHandler.setHandle((fixedHeader, originalMessage) ->
pubrelRetransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttMessage(fixedHeader, originalMessage.variableHeader())));
this.pubrelRetransmissionHandler.start(eventLoop);
pubrelRetransmissionHandler.start(eventLoop);
}
void onPubcompReceived() {
this.pubrelRetransmissionHandler.stop();
pubrelRetransmissionHandler.stop();
}
void onChannelClosed() {
this.publishRetransmissionHandler.stop();
this.pubrelRetransmissionHandler.stop();
publishRetransmissionHandler.stop();
pubrelRetransmissionHandler.stop();
if (payload != null) {
payload.release();
}
}
static Builder builder() {
return new Builder();
}
static class Builder {
private int messageId;
private Promise<Void> future;
private ByteBuf payload;
private MqttPublishMessage message;
private MqttQoS qos;
private String ownerId;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
private PendingOperation pendingOperation;
Builder messageId(int messageId) {
this.messageId = messageId;
return this;
}
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
Builder payload(ByteBuf payload) {
this.payload = payload;
return this;
}
Builder message(MqttPublishMessage message) {
this.message = message;
return this;
}
Builder qos(MqttQoS qos) {
this.qos = qos;
return this;
}
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingPublish build() {
return new MqttPendingPublish(messageId, future, payload, message, qos, ownerId, retransmissionConfig, pendingOperation);
}
}
}

119
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingSubscription.java

@ -18,90 +18,123 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttSubscribeMessage;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import java.util.HashSet;
import java.util.Set;
import java.util.function.Consumer;
import static java.util.Objects.requireNonNullElseGet;
@Getter(AccessLevel.PACKAGE)
final class MqttPendingSubscription {
private final Promise<Void> future;
private final String topic;
private final Set<MqttPendingHandler> handlers = new HashSet<>();
private final Set<MqttPendingHandler> handlers;
private final MqttSubscribeMessage subscribeMessage;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttSubscribeMessage> retransmissionHandler;
@Setter(AccessLevel.PACKAGE)
private boolean sent = false;
MqttPendingSubscription(Promise<Void> future, String topic, MqttSubscribeMessage message, PendingOperation operation) {
private MqttPendingSubscription(
Promise<Void> future,
String topic,
Set<MqttPendingHandler> handlers,
MqttSubscribeMessage subscribeMessage,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation operation
) {
this.future = future;
this.topic = topic;
this.subscribeMessage = message;
this.handlers = requireNonNullElseGet(handlers, HashSet::new);
this.subscribeMessage = subscribeMessage;
this.retransmissionHandler = new RetransmissionHandler<>(operation);
this.retransmissionHandler.setOriginalMessage(message);
retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId);
retransmissionHandler.setOriginalMessage(subscribeMessage);
}
Promise<Void> getFuture() {
return future;
}
record MqttPendingHandler(MqttHandler handler, boolean once) {}
String getTopic() {
return topic;
void addHandler(MqttHandler handler, boolean once) {
handlers.add(new MqttPendingHandler(handler, once));
}
boolean isSent() {
return sent;
void startRetransmitTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
if (sent) { // If the packet is sent, we can start the retransmission timer
retransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
retransmissionHandler.start(eventLoop);
}
}
void setSent(boolean sent) {
this.sent = sent;
void onSubackReceived() {
retransmissionHandler.stop();
}
MqttSubscribeMessage getSubscribeMessage() {
return subscribeMessage;
void onChannelClosed() {
retransmissionHandler.stop();
}
void addHandler(MqttHandler handler, boolean once) {
this.handlers.add(new MqttPendingHandler(handler, once));
static Builder builder() {
return new Builder();
}
Set<MqttPendingHandler> getHandlers() {
return handlers;
}
static class Builder {
void startRetransmitTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
if (this.sent) { //If the packet is sent, we can start the retransmit timer
this.retransmissionHandler.setHandle((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttSubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
this.retransmissionHandler.start(eventLoop);
private Promise<Void> future;
private String topic;
private Set<MqttPendingHandler> handlers;
private MqttSubscribeMessage subscribeMessage;
private String ownerId;
private PendingOperation pendingOperation;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
}
void onSubackReceived() {
this.retransmissionHandler.stop();
}
Builder topic(String topic) {
this.topic = topic;
return this;
}
final class MqttPendingHandler {
private final MqttHandler handler;
private final boolean once;
Builder handlers(Set<MqttPendingHandler> handlers) {
this.handlers = handlers;
return this;
}
MqttPendingHandler(MqttHandler handler, boolean once) {
this.handler = handler;
this.once = once;
Builder subscribeMessage(MqttSubscribeMessage subscribeMessage) {
this.subscribeMessage = subscribeMessage;
return this;
}
MqttHandler getHandler() {
return handler;
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
boolean isOnce() {
return once;
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingSubscription build() {
return new MqttPendingSubscription(future, topic, handlers, subscribeMessage, ownerId, retransmissionConfig, pendingOperation);
}
}
void onChannelClosed() {
this.retransmissionHandler.stop();
}
}

85
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPendingUnsubscription.java

@ -18,43 +18,96 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage;
import io.netty.util.concurrent.Promise;
import lombok.AccessLevel;
import lombok.Getter;
import java.util.function.Consumer;
final class MqttPendingUnsubscription{
@Getter(AccessLevel.PACKAGE)
final class MqttPendingUnsubscription {
private final Promise<Void> future;
private final String topic;
@Getter(AccessLevel.NONE)
private final RetransmissionHandler<MqttUnsubscribeMessage> retransmissionHandler;
MqttPendingUnsubscription(Promise<Void> future, String topic, MqttUnsubscribeMessage unsubscribeMessage, PendingOperation operation) {
private MqttPendingUnsubscription(
Promise<Void> future,
String topic,
MqttUnsubscribeMessage unsubscribeMessage,
String ownerId,
MqttClientConfig.RetransmissionConfig retransmissionConfig,
PendingOperation operation
) {
this.future = future;
this.topic = topic;
this.retransmissionHandler = new RetransmissionHandler<>(operation);
this.retransmissionHandler.setOriginalMessage(unsubscribeMessage);
retransmissionHandler = new RetransmissionHandler<>(retransmissionConfig, operation, ownerId);
retransmissionHandler.setOriginalMessage(unsubscribeMessage);
}
Promise<Void> getFuture() {
return future;
void startRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
retransmissionHandler.setHandler((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
retransmissionHandler.start(eventLoop);
}
String getTopic() {
return topic;
void onUnsubackReceived() {
retransmissionHandler.stop();
}
void startRetransmissionTimer(EventLoop eventLoop, Consumer<Object> sendPacket) {
this.retransmissionHandler.setHandle((fixedHeader, originalMessage) ->
sendPacket.accept(new MqttUnsubscribeMessage(fixedHeader, originalMessage.variableHeader(), originalMessage.payload())));
this.retransmissionHandler.start(eventLoop);
void onChannelClosed() {
retransmissionHandler.stop();
}
void onUnsubackReceived(){
this.retransmissionHandler.stop();
static Builder builder() {
return new Builder();
}
void onChannelClosed(){
this.retransmissionHandler.stop();
static class Builder {
private Promise<Void> future;
private String topic;
private MqttUnsubscribeMessage unsubscribeMessage;
private String ownerId;
private PendingOperation pendingOperation;
private MqttClientConfig.RetransmissionConfig retransmissionConfig;
Builder future(Promise<Void> future) {
this.future = future;
return this;
}
Builder topic(String topic) {
this.topic = topic;
return this;
}
Builder unsubscribeMessage(MqttUnsubscribeMessage unsubscribeMessage) {
this.unsubscribeMessage = unsubscribeMessage;
return this;
}
Builder ownerId(String ownerId) {
this.ownerId = ownerId;
return this;
}
Builder retransmissionConfig(MqttClientConfig.RetransmissionConfig retransmissionConfig) {
this.retransmissionConfig = retransmissionConfig;
return this;
}
Builder pendingOperation(PendingOperation pendingOperation) {
this.pendingOperation = pendingOperation;
return this;
}
MqttPendingUnsubscription build() {
return new MqttPendingUnsubscription(future, topic, unsubscribeMessage, ownerId, retransmissionConfig, pendingOperation);
}
}
}

17
netty-mqtt/src/main/java/org/thingsboard/mqtt/MqttPingHandler.java

@ -42,12 +42,11 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
if (!(msg instanceof MqttMessage)) {
public void channelRead(ChannelHandlerContext ctx, Object msg) {
if (!(msg instanceof MqttMessage message)) {
ctx.fireChannelRead(msg);
return;
}
MqttMessage message = (MqttMessage) msg;
if (message.fixedHeader().messageType() == MqttMessageType.PINGREQ) {
this.handlePingReq(ctx.channel());
} else if (message.fixedHeader().messageType() == MqttMessageType.PINGRESP) {
@ -61,28 +60,29 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
super.userEventTriggered(ctx, evt);
if (evt instanceof IdleStateEvent) {
IdleStateEvent event = (IdleStateEvent) evt;
if (evt instanceof IdleStateEvent event) {
switch (event.state()) {
case READER_IDLE:
log.debug("[{}] No reads were performed for specified period for channel {}", event.state(), ctx.channel().id());
this.sendPingReq(ctx.channel());
this.sendPingReq(ctx.channel(), event);
break;
case WRITER_IDLE:
log.debug("[{}] No writes were performed for specified period for channel {}", event.state(), ctx.channel().id());
this.sendPingReq(ctx.channel());
this.sendPingReq(ctx.channel(), event);
break;
}
}
}
private void sendPingReq(Channel channel) {
private void sendPingReq(Channel channel, IdleStateEvent idleEvent) {
log.trace("[{}] Sending ping request", channel.id());
MqttFixedHeader fixedHeader = new MqttFixedHeader(MqttMessageType.PINGREQ, false, MqttQoS.AT_MOST_ONCE, false, 0);
channel.writeAndFlush(new MqttMessage(fixedHeader));
if (this.pingRespTimeout == null) {
log.trace("[{}] Scheduling disconnect due to {}", channel.id(), idleEvent);
this.pingRespTimeout = channel.eventLoop().schedule(() -> {
log.trace("[{}] Sending disconnect due to {}", channel.id(), idleEvent);
MqttFixedHeader fixedHeader2 = new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0);
channel.writeAndFlush(new MqttMessage(fixedHeader2)).addListener(ChannelFutureListener.CLOSE);
//TODO: what do when the connection is closed ?
@ -99,6 +99,7 @@ final class MqttPingHandler extends ChannelInboundHandlerAdapter {
private void handlePingResp(Channel channel) {
log.trace("[{}] Handling ping response", channel.id());
if (this.pingRespTimeout != null && !this.pingRespTimeout.isCancelled() && !this.pingRespTimeout.isDone()) {
log.trace("[{}] Cancelling disconnect due to idle event because ping response was received", channel.id());
this.pingRespTimeout.cancel(true);
this.pingRespTimeout = null;
}

4
netty-mqtt/src/main/java/org/thingsboard/mqtt/PendingOperation.java

@ -17,6 +17,8 @@ package org.thingsboard.mqtt;
public interface PendingOperation {
boolean isCanceled();
boolean isCancelled();
void onMaxRetransmissionAttemptsReached();
}

101
netty-mqtt/src/main/java/org/thingsboard/mqtt/RetransmissionHandler.java

@ -18,66 +18,119 @@ package org.thingsboard.mqtt;
import io.netty.channel.EventLoop;
import io.netty.handler.codec.mqtt.MqttFixedHeader;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttPublishVariableHeader;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.ScheduledFuture;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.function.BiConsumer;
@Slf4j
@RequiredArgsConstructor
final class RetransmissionHandler<T extends MqttMessage> {
private volatile boolean stopped;
private final MqttClientConfig.RetransmissionConfig config;
private final PendingOperation pendingOperation;
private volatile boolean stopped;
private ScheduledFuture<?> timer;
private int timeout = 10;
private int attemptCount = 0;
@Setter
private BiConsumer<MqttFixedHeader, T> handler;
// the three fields below are used for logging only
private final String ownerId;
private String originalMessageId;
private long totalWaitingTimeMillis;
private T originalMessage;
void setOriginalMessage(T originalMessage) {
this.originalMessage = originalMessage;
var variableHeader = originalMessage.variableHeader();
if (variableHeader instanceof MqttMessageIdVariableHeader messageIdVariableHeader) {
originalMessageId = String.valueOf(messageIdVariableHeader.messageId());
} else if (variableHeader instanceof MqttPublishVariableHeader publishVariableHeader) {
originalMessageId = String.valueOf(publishVariableHeader.packetId());
} else {
originalMessageId = "N/A";
}
}
void start(EventLoop eventLoop) {
if (eventLoop == null) {
throw new NullPointerException("eventLoop");
}
if (this.handler == null) {
if (handler == null) {
throw new NullPointerException("handler");
}
this.timeout = 10;
this.startTimer(eventLoop);
log.debug("{}MessageID[{}] Starting retransmission handler", ownerId, originalMessageId);
startTimer(eventLoop);
}
private void startTimer(EventLoop eventLoop) {
if (stopped || pendingOperation.isCanceled()) {
if (stopped || pendingOperation.isCancelled()) {
return;
}
this.timer = eventLoop.schedule(() -> {
if (stopped || pendingOperation.isCanceled()) {
// Calculate the base delay using exponential backoff.
// For attemptCount == 0, delay = initial delay; for each subsequent attempt, the base delay doubles.
long baseDelay = config.initialDelayMillis() * (long) Math.pow(2, attemptCount);
// Apply jitter: random factor between (1 - jitterFactor) and (1 + jitterFactor).
double minFactor = 1.0 - config.jitterFactor();
double maxFactor = 1.0 + config.jitterFactor();
double randomFactor = config.jitterFactor() == 0 ? 1 : ThreadLocalRandom.current().nextDouble(minFactor, maxFactor);
long delayMillisWithJitter = (long) (baseDelay * randomFactor);
totalWaitingTimeMillis += delayMillisWithJitter;
timer = eventLoop.schedule(() -> {
if (stopped || pendingOperation.isCancelled()) {
return;
}
this.timeout += 5;
boolean isDup = this.originalMessage.fixedHeader().isDup();
if (this.originalMessage.fixedHeader().messageType() == MqttMessageType.PUBLISH && this.originalMessage.fixedHeader().qosLevel() != MqttQoS.AT_MOST_ONCE) {
isDup = true;
attemptCount++;
if (attemptCount > config.maxAttempts()) {
log.debug(
"{}MessageID[{}] Gave up after {} retransmission attempts; waited a total of {} ms without receiving acknowledgement",
ownerId, originalMessageId, config.maxAttempts(), totalWaitingTimeMillis
);
stop();
pendingOperation.onMaxRetransmissionAttemptsReached();
return;
}
MqttFixedHeader fixedHeader = new MqttFixedHeader(this.originalMessage.fixedHeader().messageType(), isDup, this.originalMessage.fixedHeader().qosLevel(), this.originalMessage.fixedHeader().isRetain(), this.originalMessage.fixedHeader().remainingLength());
handler.accept(fixedHeader, originalMessage);
log.debug("{}MessageID[{}] Retransmission attempt #{} out of {}", ownerId, originalMessageId, attemptCount, config.maxAttempts());
var originalFixedHeader = originalMessage.fixedHeader();
var newFixedHeader = new MqttFixedHeader(
originalFixedHeader.messageType(),
isDup(originalFixedHeader),
originalFixedHeader.qosLevel(),
originalFixedHeader.isRetain(),
originalFixedHeader.remainingLength()
);
handler.accept(newFixedHeader, originalMessage);
startTimer(eventLoop);
}, timeout, TimeUnit.SECONDS);
}, delayMillisWithJitter, TimeUnit.MILLISECONDS);
}
private static boolean isDup(MqttFixedHeader originalFixedHeader) {
return originalFixedHeader.isDup() || (originalFixedHeader.messageType() == MqttMessageType.PUBLISH && originalFixedHeader.qosLevel() != MqttQoS.AT_MOST_ONCE);
}
void stop() {
log.debug("{}MessageID[{}] Stopping retransmission handler", ownerId, originalMessageId);
stopped = true;
if (this.timer != null) {
this.timer.cancel(true);
if (timer != null) {
timer.cancel(true);
}
}
void setHandle(BiConsumer<MqttFixedHeader, T> runnable) {
this.handler = runnable;
}
void setOriginalMessage(T originalMessage) {
this.originalMessage = originalMessage;
}
}

210
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttClientTest.java

@ -0,0 +1,210 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt;
import com.google.common.util.concurrent.Futures;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.ResourceLeakDetector;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.hivemq.HiveMQContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import org.thingsboard.common.util.AbstractListeningExecutor;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@Testcontainers
class MqttClientTest {
final int randomPort = 0;
@Container
HiveMQContainer broker = new HiveMQContainer(DockerImageName.parse("hivemq/hivemq-ce").withTag("2025.2"));
MqttTestProxy proxy;
MqttClient client;
AbstractListeningExecutor handlerExecutor;
@BeforeAll
static void init() {
ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.PARANOID);
}
@BeforeEach
void setup() {
handlerExecutor = new AbstractListeningExecutor() {
@Override
protected int getThreadPollSize() {
return 1;
}
};
handlerExecutor.init();
}
@AfterEach
void cleanup() {
if (client != null) {
client.disconnect();
client = null;
}
if (proxy != null) {
proxy.stop();
proxy = null;
}
handlerExecutor.destroy();
handlerExecutor = null;
}
@Test
void testConnectToBroker() {
// GIVEN
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[ConnectToBroker]");
clientConfig.setClientId("connect");
client = MqttClient.create(clientConfig, null, handlerExecutor);
// WHEN
Promise<MqttConnectResult> connectFuture = client.connect(broker.getHost(), broker.getMqttPort());
// THEN
assertThat(connectFuture).isNotNull();
Awaitility.await("waiting for client to connect")
.atMost(Duration.ofSeconds(10L))
.until(connectFuture::isDone);
assertThat(connectFuture.isSuccess()).isTrue();
MqttConnectResult actualConnectResult = connectFuture.getNow();
assertThat(actualConnectResult).isNotNull();
assertThat(actualConnectResult.isSuccess()).isTrue();
assertThat(actualConnectResult.getReturnCode()).isEqualTo(MqttConnectReturnCode.CONNECTION_ACCEPTED);
assertThat(client.isConnected()).isTrue();
}
@Test
void testDisconnectDueToKeepAliveIfNoActivity() {
// GIVEN
proxy = MqttTestProxy.builder()
.localPort(randomPort)
.brokerHost(broker.getHost())
.brokerPort(broker.getMqttPort())
.brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PINGRESP) // drop all ping responses to simulate broker down
.build();
int idleTimeoutSeconds = 2;
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[KeepAliveDisconnect]");
clientConfig.setClientId("no-activity-disconnect");
clientConfig.setTimeoutSeconds(idleTimeoutSeconds);
clientConfig.setReconnect(false); // disable auto reconnect
client = MqttClient.create(clientConfig, null, handlerExecutor);
// WHEN-THEN
connect(broker.getHost(), proxy.getPort());
// no activity...
Awaitility.await("waiting for client to disconnect")
.pollDelay(Duration.ofSeconds(idleTimeoutSeconds * 2)) // 2 seconds to wait for the first idle event and then 2 seconds for scheduled disconnect to fire
.atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(client.isConnected()).isFalse());
}
@Test
void testRetransmission() {
// GIVEN
proxy = MqttTestProxy.builder()
.localPort(randomPort)
.brokerHost(broker.getHost())
.brokerPort(broker.getMqttPort())
.brokerToClientInterceptor(msg -> msg.fixedHeader().messageType() != MqttMessageType.PUBACK) // drop all pubacks to allow retransmission to happen
.build();
// create client
var clientConfig = new MqttClientConfig();
clientConfig.setOwnerId("Test[Retransmission]");
clientConfig.setClientId("retransmission");
clientConfig.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(1, 1000L, 0d));
client = MqttClient.create(clientConfig, null, handlerExecutor);
// connect to a broker
connect(broker.getHost(), proxy.getPort());
// subscribe to a topic
String topic = "test-topic";
List<ByteBuf> receivedMessages = Collections.synchronizedList(new ArrayList<>(2));
Future<Void> subscribeFuture = client.on(topic, (__, payload) -> {
receivedMessages.add(payload);
return Futures.immediateVoidFuture();
});
Awaitility.await("waiting for client to subscribe to a topic")
.atMost(Duration.ofSeconds(10L))
.until(subscribeFuture::isDone);
// WHEN
// publish a message
ByteBuf message = PooledByteBufAllocator.DEFAULT.buffer().writeBytes("test message".getBytes(StandardCharsets.UTF_8));
client.publish(topic, message, MqttQoS.AT_LEAST_ONCE);
// THEN
// wait enough time so that retransmission happens and stops
// if retransmission works incorrectly waiting 10 seconds allows for additional retransmissions to happen
try {
Awaitility.await("wait up to 10s, stop early if too many messages")
.atMost(Duration.ofSeconds(10L))
.pollInterval(Duration.ofMillis(100))
.until(() -> receivedMessages.size() > 2);
} catch (ConditionTimeoutException __) {
// didn't exceed 2 messages
}
assertThat(receivedMessages).size().describedAs("incorrect number of messages received, expected 2 (original plus one retransmitted)").isEqualTo(2);
}
private void connect(String host, int port) {
Promise<MqttConnectResult> connectFuture = client.connect(host, port);
Awaitility.await("waiting for client to connect")
.atMost(Duration.ofSeconds(10L))
.until(connectFuture::isSuccess);
}
}

63
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttPingHandlerTest.java

@ -1,63 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.DefaultEventLoop;
import io.netty.handler.timeout.IdleStateEvent;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.concurrent.TimeUnit;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.after;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MqttPingHandlerTest {
static final int KEEP_ALIVE_SECONDS = 0;
static final int PROCESS_SEND_DISCONNECT_MSG_TIME_MS = 500;
MqttPingHandler mqttPingHandler;
@BeforeEach
void setUp() {
mqttPingHandler = new MqttPingHandler(KEEP_ALIVE_SECONDS);
}
@Test
void givenChannelReaderIdleState_whenNoPingResponse_thenDisconnectClient() throws Exception {
ChannelHandlerContext ctx = mock(ChannelHandlerContext.class);
Channel channel = mock(Channel.class);
when(ctx.channel()).thenReturn(channel);
when(channel.eventLoop()).thenReturn(new DefaultEventLoop());
ChannelFuture channelFuture = mock(ChannelFuture.class);
when(channel.writeAndFlush(any())).thenReturn(channelFuture);
mqttPingHandler.userEventTriggered(ctx, IdleStateEvent.FIRST_READER_IDLE_STATE_EVENT);
verify(
channelFuture,
after(TimeUnit.SECONDS.toMillis(KEEP_ALIVE_SECONDS) + PROCESS_SEND_DISCONNECT_MSG_TIME_MS)
).addListener(eq(ChannelFutureListener.CLOSE));
}
}

202
netty-mqtt/src/test/java/org/thingsboard/mqtt/MqttTestProxy.java

@ -0,0 +1,202 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt;
import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFuture;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.mqtt.MqttDecoder;
import io.netty.handler.codec.mqtt.MqttEncoder;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.util.ReferenceCountUtil;
import lombok.extern.slf4j.Slf4j;
import java.net.InetSocketAddress;
import java.util.function.Predicate;
@Slf4j
public class MqttTestProxy {
private final EventLoopGroup bossGroup;
private final EventLoopGroup workerGroup;
private Channel clientToProxyChannel;
private Channel proxyToBrokerChannel;
private final int assignedPort;
private boolean stopped;
private final Predicate<MqttMessage> brokerToClientInterceptor;
private MqttTestProxy(Builder builder) {
log.info("Starting MQTT proxy...");
brokerToClientInterceptor = builder.brokerToClientInterceptor != null ? builder.brokerToClientInterceptor : msg -> true;
bossGroup = new NioEventLoopGroup(1);
workerGroup = new NioEventLoopGroup(1);
ServerBootstrap proxyBootstrap = new ServerBootstrap();
proxyBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
clientToProxyChannel = channel;
clientToProxyChannel.config().setAutoRead(false); // do not accept data before we connected to a broker
connectToBroker(builder.brokerHost, builder.brokerPort).addListener(future -> {
if (future.isSuccess()) {
clientToProxyChannel.pipeline().addLast("mqttDecoder", new MqttDecoder());
clientToProxyChannel.pipeline().addLast("mqttToBroker", new MqttRelayHandler(proxyToBrokerChannel, null));
clientToProxyChannel.pipeline().addLast("mqttEncoder", MqttEncoder.INSTANCE);
clientToProxyChannel.config().setAutoRead(true); // start accepting data for a client
} else {
log.error("Failed to connect to broker", future.cause());
clientToProxyChannel.close();
}
});
}
});
try {
Channel proxyChannel = proxyBootstrap.bind(builder.localPort).sync().channel();
assignedPort = ((InetSocketAddress) proxyChannel.localAddress()).getPort();
} catch (Exception e) {
log.error("Failed to start MQTT proxy", e);
throw new RuntimeException("Failed to start MQTT proxy", e);
}
log.info("MQTT proxy started on port {}", assignedPort);
}
private ChannelFuture connectToBroker(String brokerHost, int brokerPort) {
Bootstrap proxyToBrokerBootstrap = new Bootstrap();
proxyToBrokerBootstrap.group(workerGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel channel) {
proxyToBrokerChannel = channel;
proxyToBrokerChannel.pipeline().addLast(new MqttDecoder());
proxyToBrokerChannel.pipeline().addLast("mqttToClient", new MqttRelayHandler(clientToProxyChannel, brokerToClientInterceptor));
proxyToBrokerChannel.pipeline().addLast(MqttEncoder.INSTANCE);
}
});
return proxyToBrokerBootstrap.connect(brokerHost, brokerPort);
}
private static class MqttRelayHandler extends SimpleChannelInboundHandler<MqttMessage> {
private final Channel targetChannel;
private final Predicate<MqttMessage> interceptor;
private MqttRelayHandler(Channel targetChannel, Predicate<MqttMessage> interceptor) {
this.targetChannel = targetChannel;
this.interceptor = interceptor;
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, MqttMessage msg) {
log.debug("Received message: {}", msg.fixedHeader().messageType());
if (interceptor == null || interceptor.test(msg)) {
if (targetChannel.isActive()) {
targetChannel.writeAndFlush(ReferenceCountUtil.retain(msg));
}
} else {
log.info("Dropping message: {}", msg.fixedHeader().messageType());
}
}
}
public void stop() {
if (stopped) {
log.info("MQTT proxy was already stopped");
return;
}
stopped = true;
log.info("Stopping MQTT proxy...");
if (clientToProxyChannel != null) {
clientToProxyChannel.close();
}
if (proxyToBrokerChannel != null) {
proxyToBrokerChannel.close();
}
if (bossGroup != null) {
bossGroup.shutdownGracefully();
}
if (workerGroup != null) {
workerGroup.shutdownGracefully();
}
log.info("MQTT proxy stopped");
}
public int getPort() {
return assignedPort;
}
public static Builder builder() {
return new Builder();
}
public static class Builder {
private int localPort;
private String brokerHost;
private int brokerPort;
private Predicate<MqttMessage> brokerToClientInterceptor;
public Builder localPort(int localPort) {
this.localPort = localPort;
return this;
}
public Builder brokerHost(String brokerHost) {
this.brokerHost = brokerHost;
return this;
}
public Builder brokerPort(int brokerPort) {
this.brokerPort = brokerPort;
return this;
}
public Builder brokerToClientInterceptor(Predicate<MqttMessage> interceptor) {
this.brokerToClientInterceptor = interceptor;
return this;
}
public MqttTestProxy build() {
return new MqttTestProxy(this);
}
}
}

151
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/MqttIntegrationTest.java

@ -1,151 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt.integration;
import io.netty.buffer.Unpooled;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttQoS;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.Promise;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.ResourceLock;
import org.thingsboard.common.util.AbstractListeningExecutor;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.mqtt.integration.server.MqttServer;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ResourceLock("port8885") // test MQTT server port
@Slf4j
public class MqttIntegrationTest {
static final String MQTT_HOST = "localhost";
static final int KEEPALIVE_TIMEOUT_SECONDS = 2;
static final long RECONNECT_DELAY_SECONDS = 10L;
EventLoopGroup eventLoopGroup;
MqttServer mqttServer;
MqttClient mqttClient;
AbstractListeningExecutor handlerExecutor;
@BeforeEach
public void init() throws Exception {
this.handlerExecutor = new AbstractListeningExecutor() {
@Override
protected int getThreadPollSize() {
return 4;
}
};
handlerExecutor.init();
this.eventLoopGroup = new NioEventLoopGroup();
this.mqttServer = new MqttServer();
this.mqttServer.init();
}
@AfterEach
public void destroy() throws InterruptedException {
if (this.mqttClient != null) {
this.mqttClient.disconnect();
}
if (this.mqttServer != null) {
this.mqttServer.shutdown();
}
if (this.eventLoopGroup != null) {
this.eventLoopGroup.shutdownGracefully(0, 0, TimeUnit.MILLISECONDS);
}
if (this.handlerExecutor != null) {
this.handlerExecutor.destroy();
}
}
@Test
public void givenActiveMqttClient_whenNoActivityForKeepAliveTimeout_thenDisconnectClient() throws Throwable {
//given
this.mqttClient = initClient();
log.warn("Sending publish messages...");
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
Thread.sleep(30);
Future<Void> pubFuture = publishMsg();
pubFuture.addListener(future -> latch.countDown());
}
log.warn("Waiting for messages acknowledgments...");
boolean awaitResult = latch.await(10, TimeUnit.SECONDS);
Assertions.assertTrue(awaitResult);
log.warn("Messages are delivered successfully...");
//when
log.warn("Starting idle period...");
Thread.sleep(5000);
//then
List<MqttMessageType> allReceivedEvents = this.mqttServer.getEventsFromClient();
long disconnectCount = allReceivedEvents.stream().filter(type -> type == MqttMessageType.DISCONNECT).count();
Assertions.assertEquals(1, disconnectCount);
}
private Future<Void> publishMsg() {
return this.mqttClient.publish(
"test/topic",
Unpooled.wrappedBuffer("payload".getBytes(StandardCharsets.UTF_8)),
MqttQoS.AT_MOST_ONCE);
}
private MqttClient initClient() throws Exception {
MqttClientConfig config = new MqttClientConfig();
config.setOwnerId("MqttIntegrationTest");
config.setTimeoutSeconds(KEEPALIVE_TIMEOUT_SECONDS);
config.setReconnectDelay(RECONNECT_DELAY_SECONDS);
MqttClient client = MqttClient.create(config, null, handlerExecutor);
client.setEventLoop(this.eventLoopGroup);
Promise<MqttConnectResult> connectFuture = client.connect(MQTT_HOST, this.mqttServer.getMqttPort());
String hostPort = MQTT_HOST + ":" + this.mqttServer.getMqttPort();
MqttConnectResult result;
try {
result = connectFuture.get(10, TimeUnit.SECONDS);
} catch (TimeoutException ex) {
connectFuture.cancel(true);
client.disconnect();
throw new RuntimeException(String.format("Failed to connect to MQTT server at %s.", hostPort));
}
if (!result.isSuccess()) {
connectFuture.cancel(true);
client.disconnect();
throw new RuntimeException(String.format("Failed to connect to MQTT server at %s. Result code is: %s", hostPort, result.getReturnCode()));
}
return client;
}
}

84
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttServer.java

@ -1,84 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt.integration.server;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelOption;
import io.netty.channel.ChannelPipeline;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.mqtt.MqttDecoder;
import io.netty.handler.codec.mqtt.MqttEncoder;
import io.netty.handler.codec.mqtt.MqttMessageType;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
@Slf4j
public class MqttServer {
@Getter
private final List<MqttMessageType> eventsFromClient = new CopyOnWriteArrayList<>();
@Getter
private final int mqttPort = 8885;
private Channel serverChannel;
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
public void init() throws Exception {
log.info("Starting MQTT server on port {}...", mqttPort);
bossGroup = new NioEventLoopGroup();
workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("decoder", new MqttDecoder(65536));
pipeline.addLast("encoder", MqttEncoder.INSTANCE);
MqttTransportHandler handler = new MqttTransportHandler(eventsFromClient);
pipeline.addLast(handler);
ch.closeFuture().addListener(handler);
}
})
.childOption(ChannelOption.SO_KEEPALIVE, true);
serverChannel = b.bind(mqttPort).sync().channel();
log.info("Mqtt transport started!");
}
public void shutdown() throws InterruptedException {
log.info("Stopping MQTT transport!");
try {
serverChannel.close().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
log.info("MQTT transport stopped!");
}
}

141
netty-mqtt/src/test/java/org/thingsboard/mqtt/integration/server/MqttTransportHandler.java

@ -1,141 +0,0 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.mqtt.integration.server;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.mqtt.MqttConnAckMessage;
import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader;
import io.netty.handler.codec.mqtt.MqttConnectMessage;
import io.netty.handler.codec.mqtt.MqttConnectReturnCode;
import io.netty.handler.codec.mqtt.MqttFixedHeader;
import io.netty.handler.codec.mqtt.MqttMessage;
import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader;
import io.netty.handler.codec.mqtt.MqttMessageType;
import io.netty.handler.codec.mqtt.MqttPubAckMessage;
import io.netty.handler.codec.mqtt.MqttPublishMessage;
import io.netty.util.ReferenceCountUtil;
import io.netty.util.concurrent.Future;
import io.netty.util.concurrent.GenericFutureListener;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
import java.util.UUID;
import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK;
import static io.netty.handler.codec.mqtt.MqttMessageType.CONNECT;
import static io.netty.handler.codec.mqtt.MqttMessageType.DISCONNECT;
import static io.netty.handler.codec.mqtt.MqttMessageType.PINGREQ;
import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK;
import static io.netty.handler.codec.mqtt.MqttMessageType.PUBLISH;
import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE;
@Slf4j
public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener<Future<? super Void>> {
private final List<MqttMessageType> eventsFromClient;
private final UUID sessionId;
MqttTransportHandler(List<MqttMessageType> eventsFromClient) {
this.sessionId = UUID.randomUUID();
this.eventsFromClient = eventsFromClient;
}
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
log.trace("[{}] Processing msg: {}", sessionId, msg);
try {
if (msg instanceof MqttMessage) {
MqttMessage message = (MqttMessage) msg;
if (message.decoderResult().isSuccess()) {
processMqttMsg(ctx, message);
} else {
log.error("[{}] Message decoding failed: {}", sessionId, message.decoderResult().cause().getMessage());
ctx.close();
}
} else {
log.debug("[{}] Received non mqtt message: {}", sessionId, msg.getClass().getSimpleName());
ctx.close();
}
} finally {
ReferenceCountUtil.safeRelease(msg);
}
}
void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) {
if (msg.fixedHeader() == null) {
ctx.close();
return;
}
switch (msg.fixedHeader().messageType()) {
case CONNECT:
eventsFromClient.add(CONNECT);
processConnect(ctx, (MqttConnectMessage) msg);
break;
case DISCONNECT:
eventsFromClient.add(DISCONNECT);
ctx.close();
break;
case PUBLISH:
// QoS 0 and 1 supported only here
eventsFromClient.add(PUBLISH);
MqttPublishMessage mqttPubMsg = (MqttPublishMessage) msg;
ack(ctx, mqttPubMsg.variableHeader().packetId());
break;
case PINGREQ:
// We will not handle PINGREQ and will not send any PINGRESP to simulate the MQTT server is down
eventsFromClient.add(PINGREQ);
break;
default:
break;
}
}
void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) {
String userName = msg.payload().userName();
String clientId = msg.payload().clientIdentifier();
log.warn("[{}][{}] Processing connect msg for client: {}!", sessionId, userName, clientId);
ctx.writeAndFlush(createMqttConnAckMsg(msg));
}
private MqttConnAckMessage createMqttConnAckMsg(MqttConnectMessage msg) {
MqttFixedHeader mqttFixedHeader =
new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0);
MqttConnAckVariableHeader mqttConnAckVariableHeader =
new MqttConnAckVariableHeader(MqttConnectReturnCode.CONNECTION_ACCEPTED, !msg.variableHeader().isCleanSession());
return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader);
}
private void ack(ChannelHandlerContext ctx, int msgId) {
if (msgId > 0) {
ctx.writeAndFlush(createMqttPubAckMsg(msgId));
}
}
public static MqttPubAckMessage createMqttPubAckMsg(int requestId) {
MqttFixedHeader mqttFixedHeader =
new MqttFixedHeader(PUBACK, false, AT_MOST_ONCE, false, 0);
MqttMessageIdVariableHeader mqttMsgIdVariableHeader =
MqttMessageIdVariableHeader.from(requestId);
return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader);
}
@Override
public void operationComplete(Future<? super Void> future) {
log.trace("[{}] Channel closed!", sessionId);
}
}

3
netty-mqtt/src/test/resources/junit-platform.properties

@ -1,3 +0,0 @@
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent

6
pom.xml

@ -1973,6 +1973,12 @@
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>hivemq</artifactId>
<version>${testcontainers.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>

26
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MqttClientSettings.java

@ -0,0 +1,26 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.api;
public interface MqttClientSettings {
int getRetransmissionMaxAttempts();
long getRetransmissionInitialDelayMillis();
double getRetransmissionJitterFactor();
}

5
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java

@ -416,4 +416,9 @@ public interface TbContext {
EventService getEventService();
AuditLogService getAuditLogService();
// Configuration parameters for the MQTT client that is used in the MQTT node and Azure IoT hub node
MqttClientSettings getMqttClientSettings();
}

8
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/mqtt/TbMqttNode.java

@ -26,6 +26,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -126,6 +127,13 @@ public class TbMqttNode extends TbAbstractExternalNode {
}
config.setCleanSession(this.mqttNodeConfiguration.isCleanSession());
MqttClientSettings mqttClientSettings = ctx.getMqttClientSettings();
config.setRetransmissionConfig(new MqttClientConfig.RetransmissionConfig(
mqttClientSettings.getRetransmissionMaxAttempts(),
mqttClientSettings.getRetransmissionInitialDelayMillis(),
mqttClientSettings.getRetransmissionJitterFactor()
));
prepareMqttClientConfig(config);
MqttClient client = getMqttClient(ctx, config);
client.setEventLoop(ctx.getSharedEventLoop());

18
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/mqtt/TbMqttNodeTest.java

@ -40,6 +40,7 @@ import org.thingsboard.mqtt.MqttClient;
import org.thingsboard.mqtt.MqttClientConfig;
import org.thingsboard.mqtt.MqttConnectResult;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.MqttClientSettings;
import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration;
@ -80,6 +81,7 @@ import static org.mockito.BDDMockito.spy;
import static org.mockito.BDDMockito.then;
import static org.mockito.BDDMockito.willAnswer;
import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class)
public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
@ -106,6 +108,22 @@ public class TbMqttNodeTest extends AbstractRuleNodeUpgradeTest {
protected void setUp() {
mqttNode = spy(new TbMqttNode());
mqttNodeConfig = new TbMqttNodeConfiguration().defaultConfiguration();
lenient().when(ctxMock.getMqttClientSettings()).thenReturn(new MqttClientSettings() {
@Override
public int getRetransmissionMaxAttempts() {
return 3;
}
@Override
public long getRetransmissionInitialDelayMillis() {
return 5000L;
}
@Override
public double getRetransmissionJitterFactor() {
return 0.15;
}
});
}
@Test

9
ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts

@ -43,7 +43,6 @@ import {
isDefined,
isDefinedAndNotNull,
isNotEmptyStr,
isNumber,
isObject,
isUndefined
} from '@core/utils';
@ -77,6 +76,8 @@ import {
getHeaderTitle,
getRowStyleInfo,
getTableCellButtonActions,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
prepareTableCellButtonActions,
RowStyleInfo,
@ -392,10 +393,10 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit,
this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'alarm, ctx');
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

10
ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.ts

@ -42,7 +42,7 @@ import {
import { IWidgetSubscription } from '@core/api/widget-api.models';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isNumber, isObject, isUndefined } from '@core/utils';
import { deepClone, hashCode, isDefined, isDefinedAndNotNull, isObject, isUndefined } from '@core/utils';
import cssjs from '@core/css/css';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { DataKeyType } from '@shared/models/telemetry/telemetry.models';
@ -75,6 +75,8 @@ import {
getHeaderTitle,
getRowStyleInfo,
getTableCellButtonActions,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
prepareTableCellButtonActions,
RowStyleInfo,
@ -311,10 +313,10 @@ export class EntitiesTableWidgetComponent extends PageComponent implements OnIni
this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'entity, ctx');
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

10
ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts

@ -36,6 +36,8 @@ import { BehaviorSubject, merge, Observable, of, ReplaySubject, Subject, throwEr
import { catchError, map, tap } from 'rxjs/operators';
import {
constructTableCssString,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
TableCellButtonActionDescriptor,
TableWidgetSettings
@ -43,7 +45,7 @@ import {
import cssjs from '@core/css/css';
import { UtilsService } from '@core/services/utils.service';
import { TranslateService } from '@ngx-translate/core';
import { hashCode, isDefined, isDefinedAndNotNull, isNumber, parseHttpErrorMessage } from '@core/utils';
import { hashCode, isDefined, isDefinedAndNotNull, parseHttpErrorMessage } from '@core/utils';
import { CollectionViewer, DataSource } from '@angular/cdk/collections';
import { emptyPageData, PageData } from '@shared/models/page/page-data';
import {
@ -207,10 +209,10 @@ export class PersistentTableComponent extends PageComponent implements OnInit, O
this.displayedColumns = [...this.displayTableColumns];
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

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

@ -16,7 +16,7 @@
import { EntityId } from '@shared/models/id/entity-id';
import { DataKey, FormattedData, WidgetActionDescriptor, WidgetConfig } from '@shared/models/widget.models';
import { getDescendantProp, isDefined, isDefinedAndNotNull, isNotEmptyStr } from '@core/utils';
import { getDescendantProp, isDefined, isNotEmptyStr } from '@core/utils';
import { AlarmDataInfo, alarmFields } from '@shared/models/alarm.models';
import tinycolor from 'tinycolor2';
import { Direction } from '@shared/models/page/sort-order';
@ -564,11 +564,18 @@ export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKey
export function buildPageStepSizeValues(pageStepCount: number, pageStepIncrement: number): Array<number> {
const pageSteps: Array<number> = [];
if (isDefinedAndNotNull(pageStepCount) && pageStepCount > 0 && pageStepCount <= 100 &&
isDefinedAndNotNull(pageStepIncrement) && pageStepIncrement > 0) {
if (isValidPageStepCount(pageStepCount) && isValidPageStepIncrement(pageStepIncrement)) {
for (let i = 1; i <= pageStepCount; i++) {
pageSteps.push(pageStepIncrement * i);
}
}
return pageSteps;
}
export function isValidPageStepIncrement(value: number): boolean {
return Number.isInteger(value) && value > 0;
}
export function isValidPageStepCount(value: number): boolean {
return Number.isInteger(value) && value > 0 && value <= 100;
}

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

@ -49,7 +49,6 @@ import {
hashCode,
isDefined,
isDefinedAndNotNull,
isNumber,
isObject,
isUndefined
} from '@core/utils';
@ -85,6 +84,8 @@ import {
getColumnSelectionAvailability,
getRowStyleInfo,
getTableCellButtonActions,
isValidPageStepCount,
isValidPageStepIncrement,
noDataMessage,
prepareTableCellButtonActions,
RowStyleInfo,
@ -352,10 +353,10 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI
this.rowStylesInfo = getRowStyleInfo(this.ctx, this.settings, 'rowData, ctx');
const pageSize = this.settings.defaultPageSize;
let pageStepIncrement = this.settings.pageStepIncrement;
let pageStepCount = this.settings.pageStepCount;
let pageStepIncrement = isValidPageStepIncrement(this.settings.pageStepIncrement) ? this.settings.pageStepIncrement : null;
let pageStepCount = isValidPageStepCount(this.settings.pageStepCount) ? this.settings.pageStepCount : null;
if (isDefined(pageSize) && isNumber(pageSize) && pageSize > 0) {
if (Number.isInteger(pageSize) && pageSize > 0) {
this.defaultPageSize = pageSize;
}

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

@ -198,7 +198,7 @@ export const HelpLinks = {
mobileApplication: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/applications/`,
mobileBundle: `${helpBaseUrl}/docs${docPlatformPrefix}/mobile-center/mobile-center/`,
mobileQrCode: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/ui/mobile-qr-code/`,
calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/`,
calculatedField: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/calculated-fields/`,
timewindowSettings: `${helpBaseUrl}/docs${docPlatformPrefix}/user-guide/dashboards/#time-window`,
}
};

34
ui-ngx/src/assets/locale/locale.constant-nl_BE.json

@ -59,8 +59,6 @@
"import": "Importeren",
"export": "Exporteren",
"share-via": "Delen via {{provider}}",
"move": "Verplaatsen",
"select": "Selecteren",
"continue": "Voortzetten",
"discard-changes": "Wijzigingen negeren",
"download": "Downloaden",
@ -291,7 +289,6 @@
"enable": "OAuth2-instellingen inschakelen",
"domains": "Domeinen",
"mobile-apps": "Mobiele applicaties",
"no-mobile-apps": "Geen applicaties geconfigureerd",
"mobile-package": "Applicatie pakket",
"mobile-package-placeholder": "Vb.: my.example.app",
"mobile-package-hint": "Voor Android: uw eigen unieke applicatie-ID. Voor iOS: ID van productbundel.",
@ -299,7 +296,6 @@
"mobile-app-secret": "Geheim van de toepassing",
"invalid-mobile-app-secret": "Het geheim van de toepassing mag alleen alfanumerieke tekens bevatten en moet tussen de 16 en 2048 tekens lang zijn.",
"copy-mobile-app-secret": "Toepassingsgeheim kopiëren",
"add-mobile-app": "Applicatie toevoegen",
"delete-mobile-app": "Toepassingsgegevens verwijderen",
"providers": "Providers",
"platform-web": "Web",
@ -627,8 +623,6 @@
"filter-type-entity-view-search-query": "Zoekquery voor entiteitsweergave",
"filter-type-entity-view-search-query-description": "Entiteitsweergaven met types {{entityViewTypes}} die {{relationType}} relatie hebben {{direction}} {{rootEntity}}",
"filter-type-apiUsageState": "Api-gebruiksstatus",
"filter-type-edge-search-query": "Edge-zoekopdracht",
"filter-type-edge-search-query-description": "Edge met types {{edgeTypes}} die {{relationType}} relatie hebben {{direction}} {{rootEntity}}",
"entity-filter": "Entiteit filteren",
"resolve-multiple": "Oplossen als meerdere entiteiten",
"resolve-multiple-hint": "Inschakelen om gegevens van alle gefilterde entiteiten tegelijk weer te geven. \nAls uitgeschakeld, toont de widget alleen gegevens van de geselecteerde entiteit.",
@ -727,7 +721,6 @@
"asset-required": "Asset is vereist",
"name-starts-with": "Expressie van itemnaam",
"help-text": "Gebruik '%' naar behoefte: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.",
"search": "Assets zoeken",
"select-group-to-add": "Selecteer doelgroep om geselecteerde assets toe te voegen",
"select-group-to-move": "Selecteer de doelgroep om geselecteerde assets te verplaatsen",
"remove-assets-from-group": "Weet je zeker dat je { count, plural, =1 {1 asset} other {# assets} } uit groep '{{entityGroup}}' wilt verwijderen?",
@ -804,7 +797,6 @@
"email-messages": "E-mailberichten",
"email-messages-daily-activity": "Dagelijkse activiteit e-mailberichten",
"email-messages-monthly-activity": "Maandelijkse activiteit e-mailberichten",
"exceptions": "Uitzonderingen",
"executions": "Executies",
"javascript": "JavaScript",
"javascript-executions": "JavaScript-uitvoeringen",
@ -820,9 +812,7 @@
"permanent-failures": "${entityName} Permanente storingen",
"permanent-timeouts": "Permanente time-outs van $ {entityName}",
"processing-failures": "${entityName} Verwerkingsfouten",
"processing-failures-and-timeouts": "Verwerkingsfouten en time-outs",
"processing-timeouts": "${entityName} time-outs voor verwerking",
"queue-stats": "Queue Statistieken",
"rule-chain": "Rule chain",
"rule-engine": "Rule engine",
"rule-engine-daily-activity": "Dagelijkse activiteit van de rule engine",
@ -969,7 +959,7 @@
"name": "Naam",
"name-required": "Naam is verplicht.",
"name-max-length": "Naam moet kleiner zijn dan 256 tekens",
"description": "Omschrijving: __________",
"description": "Omschrijving",
"decoder": "Decoder",
"encoder": "Coderingsprogramma",
"test-decoder-fuction": "Test decoder functie",
@ -1047,7 +1037,6 @@
"manage-customer-assets": "Klant devices beheren",
"manage-public-assets": "Openbare asset beheren",
"manage-customer-edges": "Beheer klant edges",
"manage-public-assets": "Openbare asset beheren",
"add-customer-text": "Nieuwe klant toevoegen",
"no-customers-text": "Geen klanten gevonden",
"customer-details": "Klantgegevens",
@ -2451,7 +2440,7 @@
"name": "Naam",
"name-required": "Naam is verplicht.",
"name-max-length": "Naam moet kleiner zijn dan 256 tekens",
"description": "Omschrijving: __________",
"description": "Omschrijving",
"add": "Entiteitsgroep toevoegen",
"open-entity-group": "Entiteitsgroep openen",
"add-entity-group-text": "Nieuwe entiteitsgroep toevoegen",
@ -2655,8 +2644,6 @@
"assign-entity-views": "Entiteitsweergaven toewijzen",
"assign-entity-views-text": "Wijs { count, plural, =1 {1 entity view} other {# entity views} } toe aan de klant",
"delete-entity-views": "Entiteitsweergaven verwijderen",
"make-public": "Entiteitsweergave openbaar maken",
"make-private": "Entiteitsweergave privé maken",
"unassign-from-customer": "Toewijzing van klant ongedaan maken",
"unassign-entity-views": "Toewijzing van entiteitsweergaven intrekken",
"unassign-entity-views-action-title": "Toewijzing { count, plural, =1 {1 entity view} other {# entity views} } van klant ongedaan maken",
@ -2666,10 +2653,6 @@
"delete-entity-views-title": "Weet u zeker dat u { count, plural, =1 {1 entity view} other {# entity views} } wilt verwijderen?",
"delete-entity-views-action-title": "Verwijder { count, plural, =1 {1 entity view} other {# entity views} }",
"delete-entity-views-text": "Opgelet, na de bevestiging worden alle geselecteerde entiteitsweergaven verwijderd en kunnen alle gerelateerde gegevens niet meer worden hersteld.",
"make-public-entity-view-title": "Weet u zeker dat u de entiteitsweergave '{{entityViewName}}' openbaar wilt maken?",
"make-public-entity-view-text": "Na de bevestiging worden de entiteitsweergave en al haar gegevens openbaar en toegankelijk gemaakt voor anderen.",
"make-private-entity-view-title": "Weet u zeker dat u de entiteitsweergave '{{entityViewName}}' privé wilt maken?",
"make-private-entity-view-text": "Na de bevestiging worden de entiteitsweergave en al zijn gegevens privé gemaakt en zijn ze niet toegankelijk voor anderen.",
"unassign-entity-view-title": "Weet u zeker dat u de toewijzing van de entiteitsweergave '{{entityViewName}}' wilt opheffen?",
"unassign-entity-view-text": "Na de bevestiging wordt de toewijzing van de entiteitsweergave ongedaan gemaakt en is deze niet toegankelijk voor de klant.",
"unassign-entity-view": "Toewijzing van entiteitsweergave ongedaan maken",
@ -2760,7 +2743,6 @@
"type": "Type",
"in": "In",
"out": "Buiten",
"metadata": "Metagegevens",
"message": "Bericht",
"entity": "Entiteit",
"message-id": "Bericht-ID",
@ -3319,7 +3301,7 @@
"name": "Naam",
"name-required": "Naam is verplicht.",
"name-max-length": "Naam moet kleiner zijn dan 256 tekens",
"description": "Omschrijving: __________",
"description": "Omschrijving",
"base-url": "Basis-URL",
"base-url-required": "Basis-URL is vereist",
"security-key": "Beveiligingssleutel",
@ -4596,7 +4578,7 @@
"name": "Naam",
"name-required": "Naam is verplicht.",
"name-max-length": "Naam moet kleiner zijn dan 256 tekens",
"description": "Omschrijving: __________",
"description": "Omschrijving",
"events": "Events",
"details": "Details",
"copyId": "Rol-ID kopiëren",
@ -4641,7 +4623,7 @@
"permissions-required": "Er moet ten minste één machtigingsvermelding worden opgegeven.",
"remove-permission": "Machtigingsinvoer verwijderen",
"add-permission": "Machtigingsinvoer toevoegen",
"other": "Anders __________",
"other": "Anders",
"resource": {
"resource": "Hulpbron",
"select-resource": "Bron selecteren",
@ -5781,12 +5763,6 @@
"delete-solution-text": "Opgelet, na de bevestiging worden de oplossing en alle gerelateerde gegevens onherstelbaar.",
"installing": "Oplossingssjabloon installeren..."
},
"markdown": {
"edit": "Bewerken",
"preview": "Voorbeeld",
"copy-code": "Klik om te kopiëren",
"copied": "Gekopieerd!"
},
"white-labeling": {
"white-labeling": "White labelling",
"white-labeling-general": "Algemene White Labeling",

Loading…
Cancel
Save