From f651f0a721c39fc6e1be59425aeff0fa206ccfa1 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 19 Mar 2026 17:27:32 +0200 Subject: [PATCH 001/123] fixed cache cleanup on tenant/entity deletion for DefaultCalculatedFieldCache, DefaultTbAssetProfileCache, DefaultTbDeviceProfileCache --- .../cf/DefaultCalculatedFieldCache.java | 47 +++++++++++++++++++ .../profile/DefaultTbAssetProfileCache.java | 30 ++++++++++++ .../profile/DefaultTbDeviceProfileCache.java | 30 ++++++++++++ 3 files changed, 107 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 1eba2c4549..57ce166014 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -19,11 +19,14 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -35,6 +38,7 @@ import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -183,6 +187,49 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + if (event.getEvent() != ComponentLifecycleEvent.DELETED) { + return; + } + switch (event.getEntityId().getEntityType()) { + case TENANT: + TenantId tenantId = event.getTenantId(); + var removedCfIds = new HashSet(); + calculatedFields.forEach((cfId, cf) -> { + if (cf.getTenantId().equals(tenantId)) { + calculatedFields.remove(cfId); + calculatedFieldLinks.remove(cfId); + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + } + }); + entityIdCalculatedFields.values().forEach(list -> list.removeIf(cf -> removedCfIds.contains(cf.getId()))); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId()))); + break; + case DEVICE: + case ASSET: + case DEVICE_PROFILE: + case ASSET_PROFILE: + EntityId entityId = event.getEntityId(); + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); + } + entityIdCalculatedFieldLinks.remove(entityId); + break; + } + } + private Lock getFetchLock(CalculatedFieldId id) { return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index 28fa68d803..a2795e6929 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -23,9 +24,12 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; +import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -143,6 +147,32 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + var removedProfileIds = new HashSet(); + assetProfilesMap.forEach((assetProfileId, assetProfile) -> { + if (assetProfile.getTenantId().equals(tenantId)) { + assetProfilesMap.remove(assetProfileId); + removedProfileIds.add(assetProfileId); + log.debug("[{}] evict asset profile from cache: {}", assetProfileId, assetProfile); + } + }); + assetsMap.forEach((assetId, assetProfileId) -> { + if (removedProfileIds.contains(assetProfileId)) { + assetsMap.remove(assetId); + } + }); + profileListeners.remove(tenantId); + assetProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(AssetProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 6b356adf94..93072a72c7 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -23,9 +24,12 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; +import java.util.HashSet; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -143,6 +147,32 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + var removedProfileIds = new HashSet(); + deviceProfilesMap.forEach((deviceProfileId, deviceProfile) -> { + if (deviceProfile.getTenantId().equals(tenantId)) { + deviceProfilesMap.remove(deviceProfileId); + removedProfileIds.add(deviceProfileId); + log.debug("[{}] evict device profile from cache: {}", deviceProfileId, deviceProfile); + } + }); + devicesMap.forEach((deviceId, deviceProfileId) -> { + if (removedProfileIds.contains(deviceProfileId)) { + devicesMap.remove(deviceId); + } + }); + profileListeners.remove(tenantId); + deviceProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(DeviceProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { From 262565a411dcfe305768798d00fafb17090b0acf Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 19 Mar 2026 19:56:07 +0200 Subject: [PATCH 002/123] refactoring --- .../cf/DefaultCalculatedFieldCache.java | 35 ++++++++++++++++--- .../profile/DefaultTbAssetProfileCache.java | 21 +++++------ .../profile/DefaultTbDeviceProfileCache.java | 21 +++++------ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 57ce166014..05c60e8f9a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -40,6 +40,7 @@ import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.Collections; import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; @@ -196,17 +197,42 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { case TENANT: TenantId tenantId = event.getTenantId(); var removedCfIds = new HashSet(); - calculatedFields.forEach((cfId, cf) -> { + var removedCfEntityIds = new HashSet(); + var removedLinkEntityIds = new HashSet(); + for (Map.Entry entry : calculatedFields.entrySet()) { + CalculatedFieldId cfId = entry.getKey(); + CalculatedField cf = entry.getValue(); if (cf.getTenantId().equals(tenantId)) { calculatedFields.remove(cfId); - calculatedFieldLinks.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); + } calculatedFieldsCtx.remove(cfId); removedCfIds.add(cfId); + removedCfEntityIds.add(cf.getEntityId()); log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); } + } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } }); - entityIdCalculatedFields.values().forEach(list -> list.removeIf(cf -> removedCfIds.contains(cf.getId()))); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId()))); + removedCfIds.forEach(calculatedFieldFetchLocks::remove); break; case DEVICE: case ASSET: @@ -224,6 +250,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); }); entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); + cfIds.forEach(calculatedFieldFetchLocks::remove); } entityIdCalculatedFieldLinks.remove(entityId); break; diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index a2795e6929..a0bae27b68 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -30,6 +30,7 @@ import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; import java.util.HashSet; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -154,18 +155,18 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); var removedProfileIds = new HashSet(); - assetProfilesMap.forEach((assetProfileId, assetProfile) -> { - if (assetProfile.getTenantId().equals(tenantId)) { - assetProfilesMap.remove(assetProfileId); - removedProfileIds.add(assetProfileId); - log.debug("[{}] evict asset profile from cache: {}", assetProfileId, assetProfile); + for (Map.Entry entry : assetProfilesMap.entrySet()) { + if (entry.getValue().getTenantId().equals(tenantId)) { + assetProfilesMap.remove(entry.getKey()); + removedProfileIds.add(entry.getKey()); + log.debug("[{}] evict asset profile from cache: {}", entry.getKey(), entry.getValue()); } - }); - assetsMap.forEach((assetId, assetProfileId) -> { - if (removedProfileIds.contains(assetProfileId)) { - assetsMap.remove(assetId); + } + for (Map.Entry entry : assetsMap.entrySet()) { + if (removedProfileIds.contains(entry.getValue())) { + assetsMap.remove(entry.getKey()); } - }); + } profileListeners.remove(tenantId); assetProfileListeners.remove(tenantId); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 93072a72c7..34b5f365f5 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -30,6 +30,7 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; import java.util.HashSet; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; @@ -154,18 +155,18 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); var removedProfileIds = new HashSet(); - deviceProfilesMap.forEach((deviceProfileId, deviceProfile) -> { - if (deviceProfile.getTenantId().equals(tenantId)) { - deviceProfilesMap.remove(deviceProfileId); - removedProfileIds.add(deviceProfileId); - log.debug("[{}] evict device profile from cache: {}", deviceProfileId, deviceProfile); + for (Map.Entry entry : deviceProfilesMap.entrySet()) { + if (entry.getValue().getTenantId().equals(tenantId)) { + deviceProfilesMap.remove(entry.getKey()); + removedProfileIds.add(entry.getKey()); + log.debug("[{}] evict device profile from cache: {}", entry.getKey(), entry.getValue()); } - }); - devicesMap.forEach((deviceId, deviceProfileId) -> { - if (removedProfileIds.contains(deviceProfileId)) { - devicesMap.remove(deviceId); + } + for (Map.Entry entry : devicesMap.entrySet()) { + if (removedProfileIds.contains(entry.getValue())) { + devicesMap.remove(entry.getKey()); } - }); + } profileListeners.remove(tenantId); deviceProfileListeners.remove(tenantId); } From f0af6882821d149458f2df5b36588c188aa9dafb Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Fri, 20 Mar 2026 16:57:41 +0200 Subject: [PATCH 003/123] refactoring, added tests --- .../cf/DefaultCalculatedFieldCache.java | 127 +++++---- ...faultTbCalculatedFieldConsumerService.java | 3 +- .../queue/DefaultTbCoreConsumerService.java | 3 +- .../queue/DefaultTbEdgeConsumerService.java | 2 +- .../DefaultTbRuleEngineConsumerService.java | 5 +- .../processing/AbstractConsumerService.java | 13 +- ...AbstractPartitionBasedConsumerService.java | 3 +- .../cf/DefaultCalculatedFieldCacheTest.java | 260 ++++++++++++++++++ .../DefaultTbAssetProfileCacheTest.java | 160 +++++++++++ .../DefaultTbDeviceProfileCacheTest.java | 160 +++++++++++ 10 files changed, 656 insertions(+), 80 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java create mode 100644 application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 05c60e8f9a..5922551a29 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -190,71 +190,82 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @EventListener(ComponentLifecycleMsg.class) public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { - if (event.getEvent() != ComponentLifecycleEvent.DELETED) { - return; - } switch (event.getEntityId().getEntityType()) { case TENANT: - TenantId tenantId = event.getTenantId(); - var removedCfIds = new HashSet(); - var removedCfEntityIds = new HashSet(); - var removedLinkEntityIds = new HashSet(); - for (Map.Entry entry : calculatedFields.entrySet()) { - CalculatedFieldId cfId = entry.getKey(); - CalculatedField cf = entry.getValue(); - if (cf.getTenantId().equals(tenantId)) { - calculatedFields.remove(cfId); - List links = calculatedFieldLinks.remove(cfId); - if (links != null) { - links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); - } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); - } + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictTenantCfs(event.getTenantId()); } - removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); - } - } - }); - removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); - } - } - }); - removedCfIds.forEach(calculatedFieldFetchLocks::remove); break; - case DEVICE: - case ASSET: - case DEVICE_PROFILE: - case ASSET_PROFILE: - EntityId entityId = event.getEntityId(); - List cfs = entityIdCalculatedFields.remove(entityId); - if (cfs != null) { - var cfIds = new HashSet(); - cfs.forEach(cf -> { - calculatedFields.remove(cf.getId()); - calculatedFieldLinks.remove(cf.getId()); - calculatedFieldsCtx.remove(cf.getId()); - cfIds.add(cf.getId()); - log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); - }); - entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); - cfIds.forEach(calculatedFieldFetchLocks::remove); + case DEVICE, ASSET, DEVICE_PROFILE, ASSET_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictEntityCfs(event.getEntityId()); } - entityIdCalculatedFieldLinks.remove(entityId); break; + case CALCULATED_FIELD: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evict((CalculatedFieldId) event.getEntityId()); + } + break; + } + } + + private void evictTenantCfs(TenantId tenantId) { + var removedCfIds = new HashSet(); + var removedCfEntityIds = new HashSet(); + var removedLinkEntityIds = new HashSet(); + for (Map.Entry entry : calculatedFields.entrySet()) { + CalculatedFieldId cfId = entry.getKey(); + CalculatedField cf = entry.getValue(); + if (cf.getTenantId().equals(tenantId)) { + calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); + } + calculatedFieldsCtx.remove(cfId); + removedCfIds.add(cfId); + removedCfEntityIds.add(cf.getEntityId()); + log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + } + } + removedCfEntityIds.forEach(entityId -> { + List cfs = entityIdCalculatedFields.get(entityId); + if (cfs != null) { + cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); + if (cfs.isEmpty()) { + entityIdCalculatedFields.remove(entityId); + } + } + }); + removedLinkEntityIds.forEach(entityId -> { + List entityLinks = entityIdCalculatedFieldLinks.get(entityId); + if (entityLinks != null) { + entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); + if (entityLinks.isEmpty()) { + entityIdCalculatedFieldLinks.remove(entityId); + } + } + }); + } + + private void evictEntityCfs(EntityId entityId) { + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.getCalculatedFieldId()))); } + entityIdCalculatedFieldLinks.remove(entityId); } private Lock getFetchLock(CalculatedFieldId id) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index 60e60b5444..ba4ab74f37 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -90,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 6399e55e03..de2e65723e 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -179,9 +179,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { + AssetProfile profile = new AssetProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { + Asset asset = new Asset(); + asset.setId(assetId); + asset.setAssetProfileId(profileId); + when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); + cache.get(tenantId, assetId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbDeviceProfileCacheTest { + + @Mock + private DeviceProfileService deviceProfileService; + @Mock + private DeviceService deviceService; + + private DefaultTbDeviceProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + // tenant2 profile should still be served from cache (no extra service call) + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadDeviceMappingIntoCache(tenant, deviceId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload + when(deviceService.findDeviceById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, deviceId)).isNull(); + verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new DeviceId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile profile = new DeviceProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { + Device device = new Device(); + device.setId(deviceId); + device.setDeviceProfileId(profileId); + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + cache.get(tenantId, deviceId); + } + +} From c033f4b5b3a248a27e7d871dfc13df04f1e83eb2 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:08:28 +0200 Subject: [PATCH 004/123] refactoring, added tests --- .../cf/DefaultCalculatedFieldCache.java | 56 +++++---- .../profile/DefaultTbAssetProfileCache.java | 23 ++-- .../profile/DefaultTbDeviceProfileCache.java | 23 ++-- .../cf/DefaultCalculatedFieldCacheTest.java | 108 ++++++++++++++++++ 4 files changed, 151 insertions(+), 59 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 5922551a29..66282135cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -25,13 +25,13 @@ import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; -import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; -import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.queue.util.AfterStartUp; @@ -46,6 +46,7 @@ import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; @Service @Slf4j @@ -214,41 +215,38 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } private void evictTenantCfs(TenantId tenantId) { - var removedCfIds = new HashSet(); var removedCfEntityIds = new HashSet(); var removedLinkEntityIds = new HashSet(); - for (Map.Entry entry : calculatedFields.entrySet()) { - CalculatedFieldId cfId = entry.getKey(); - CalculatedField cf = entry.getValue(); - if (cf.getTenantId().equals(tenantId)) { - calculatedFields.remove(cfId); - List links = calculatedFieldLinks.remove(cfId); - if (links != null) { - links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); - } - calculatedFieldsCtx.remove(cfId); - removedCfIds.add(cfId); - removedCfEntityIds.add(cf.getEntityId()); - log.debug("[{}] evict calculated field from cache on tenant deletion: {}", cfId, cf); + var toRemove = calculatedFields.entrySet().stream() + .filter(e -> e.getValue().getTenantId().equals(tenantId)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + toRemove.forEach(cfId -> { + CalculatedField cf = calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); } - } + calculatedFieldsCtx.remove(cfId); + removedCfEntityIds.add(cf.getEntityId()); + }); removedCfEntityIds.forEach(entityId -> { - List cfs = entityIdCalculatedFields.get(entityId); - if (cfs != null) { - cfs.removeIf(cf -> removedCfIds.contains(cf.getId())); - if (cfs.isEmpty()) { - entityIdCalculatedFields.remove(entityId); + entityIdCalculatedFields.compute(entityId, (k, cfs) -> { + if (cfs != null) { + cfs.removeIf(cf -> toRemove.contains(cf.getId())); + return cfs.isEmpty() ? null : cfs; } - } + return null; + }); }); removedLinkEntityIds.forEach(entityId -> { - List entityLinks = entityIdCalculatedFieldLinks.get(entityId); - if (entityLinks != null) { - entityLinks.removeIf(link -> removedCfIds.contains(link.getCalculatedFieldId())); - if (entityLinks.isEmpty()) { - entityIdCalculatedFieldLinks.remove(entityId); + entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> { + if (links != null) { + links.removeIf(link -> toRemove.contains(link.getCalculatedFieldId())); + return links.isEmpty() ? null : links; } - } + return null; + })); }); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index a0bae27b68..e0a9917509 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -29,14 +29,14 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; -import java.util.HashSet; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -154,19 +154,12 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - var removedProfileIds = new HashSet(); - for (Map.Entry entry : assetProfilesMap.entrySet()) { - if (entry.getValue().getTenantId().equals(tenantId)) { - assetProfilesMap.remove(entry.getKey()); - removedProfileIds.add(entry.getKey()); - log.debug("[{}] evict asset profile from cache: {}", entry.getKey(), entry.getValue()); - } - } - for (Map.Entry entry : assetsMap.entrySet()) { - if (removedProfileIds.contains(entry.getValue())) { - assetsMap.remove(entry.getKey()); - } - } + Set toRemove = assetProfilesMap.values().stream() + .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId)) + .map(AssetProfile::getId) + .collect(Collectors.toSet()); + assetProfilesMap.keySet().removeAll(toRemove); + assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); profileListeners.remove(tenantId); assetProfileListeners.remove(tenantId); } diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 34b5f365f5..4729a8c118 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -29,14 +29,14 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; -import java.util.HashSet; -import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -154,19 +154,12 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { case TENANT: if (event.getEvent() == ComponentLifecycleEvent.DELETED) { TenantId tenantId = event.getTenantId(); - var removedProfileIds = new HashSet(); - for (Map.Entry entry : deviceProfilesMap.entrySet()) { - if (entry.getValue().getTenantId().equals(tenantId)) { - deviceProfilesMap.remove(entry.getKey()); - removedProfileIds.add(entry.getKey()); - log.debug("[{}] evict device profile from cache: {}", entry.getKey(), entry.getValue()); - } - } - for (Map.Entry entry : devicesMap.entrySet()) { - if (removedProfileIds.contains(entry.getValue())) { - devicesMap.remove(entry.getKey()); - } - } + Set toRemove = deviceProfilesMap.values().stream() + .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId)) + .map(DeviceProfile::getId) + .collect(Collectors.toSet()); + deviceProfilesMap.keySet().removeAll(toRemove); + devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); profileListeners.remove(tenantId); deviceProfileListeners.remove(tenantId); } diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java index df0acef244..cde66dce65 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -26,9 +26,11 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -151,6 +153,112 @@ public class DefaultCalculatedFieldCacheTest { assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty(); } + // --- DeviceProfile/AssetProfile deletion tests --- + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedDevice); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + AssetId linkedAsset = new AssetId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedAsset); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + // --- CalculatedField lifecycle tests --- @Test From 7cce88f330e06c14752ba4e979fdfaf5be27428d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 24 Mar 2026 18:17:49 +0200 Subject: [PATCH 005/123] fixed potential NPE, code cleanup --- .../cf/DefaultCalculatedFieldCache.java | 4 +++- .../cf/DefaultCalculatedFieldCacheTest.java | 19 ------------------- .../DefaultTbAssetProfileCacheTest.java | 1 - 3 files changed, 3 insertions(+), 21 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 66282135cd..fc54b4b0db 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -228,7 +228,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { links.forEach(link -> removedLinkEntityIds.add(link.getEntityId())); } calculatedFieldsCtx.remove(cfId); - removedCfEntityIds.add(cf.getEntityId()); + if (cf != null) { + removedCfEntityIds.add(cf.getEntityId()); + } }); removedCfEntityIds.forEach(entityId -> { entityIdCalculatedFields.compute(entityId, (k, cfs) -> { diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java index cde66dce65..ee83f9df64 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -302,25 +302,6 @@ public class DefaultCalculatedFieldCacheTest { assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf); } - // --- Helpers --- - - private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) { - Device device = new Device(); - device.setId(deviceId); - device.setTenantId(tenantId); - if (ownerId instanceof CustomerId customerId) { - device.setCustomerId(customerId); - } - // If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId - when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); - // Stubs for getOwnedEntities iteration (empty pages — device is added explicitly) - when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData()); - when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData()); - if (ownerId instanceof TenantId) { - when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); - } - } - private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) { CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig()); diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java index 6d1a66e27b..f9b8d428d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java @@ -70,7 +70,6 @@ public class DefaultTbAssetProfileCacheTest { // After deletion tenant1 profile should be reloaded from service on next get when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null); assertThat(cache.get(tenant1, profileId1)).isNull(); - // tenant2 profile should still be served from cache (no extra service call) verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2); } From 8be7a23b15b6028a5e7071c0de1d242f02101839 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:17:42 +0200 Subject: [PATCH 006/123] Added automatic SSL/TLS certificate reload for transports without service restart --- .../src/main/resources/thingsboard.yml | 9 + .../coapserver/DefaultCoapServerService.java | 131 +++++- .../server/coapserver/TbCoapDtlsSettings.java | 6 +- .../CoapDtlsCertificateReloadTest.java | 235 +++++++++++ .../coapserver/TbCoapDtlsSettingsTest.java | 8 +- .../server/common/data/ResourceUtils.java | 19 +- .../server/common/data/ResourceUtilsTest.java | 39 ++ .../transport/http/DeviceApiController.java | 4 - .../transport/http/HttpTransportContext.java | 4 +- .../LwM2MTransportBootstrapService.java | 43 +- .../config/LwM2MTransportBootstrapConfig.java | 29 ++ .../config/LwM2MTransportServerConfig.java | 32 ++ .../server/DefaultLwM2mTransportService.java | 65 ++- .../LwM2mBootstrapCertificateReloadTest.java | 192 +++++++++ .../LwM2mServerCertificateReloadTest.java | 188 +++++++++ .../mqtt/MqttSslHandlerProvider.java | 35 +- .../transport/mqtt/MqttTransportContext.java | 3 - .../mqtt/MqttSslHandlerProviderTest.java | 197 +++++++++ .../common/transport/DeviceDeletedEvent.java | 5 + .../common/transport/SessionMsgListener.java | 3 - .../common/transport/TransportContext.java | 5 - .../common/transport/TransportService.java | 4 +- .../transport/TransportServiceCallback.java | 3 - .../config/ssl/AbstractSslCredentials.java | 93 +++-- .../config/ssl/KeystoreSslCredentials.java | 16 + .../config/ssl/PemSslCredentials.java | 49 ++- .../transport/config/ssl/SslCredentials.java | 7 + .../config/ssl/SslCredentialsConfig.java | 27 ++ .../SslCredentialsWebServerCustomizer.java | 119 ++++-- .../service/CertificateReloadManager.java | 272 +++++++++++++ .../service/DefaultTransportService.java | 16 +- .../transport/service/SessionMetaData.java | 8 +- .../service/ToRuleEngineMsgEncoder.java | 3 - .../ToTransportMsgResponseDecoder.java | 3 - .../service/TransportApiRequestEncoder.java | 3 - .../service/TransportApiResponseDecoder.java | 3 - .../session/DeviceAwareSessionContext.java | 3 - .../transport/session/SessionContext.java | 1 + .../common/transport/util/JsonUtils.java | 16 +- .../server/common/transport/util/SslUtil.java | 7 +- .../config/ssl/SslCredentialsConfigTest.java | 182 +++++++++ ...SslCredentialsWebServerCustomizerTest.java | 277 +++++++++++++ .../service/CertificateReloadManagerTest.java | 383 ++++++++++++++++++ .../src/main/resources/tb-coap-transport.yml | 9 + .../src/main/resources/tb-http-transport.yml | 9 + .../src/main/resources/tb-lwm2m-transport.yml | 9 + .../src/main/resources/tb-mqtt-transport.yml | 9 + 47 files changed, 2586 insertions(+), 197 deletions(-) create mode 100644 common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java create mode 100644 common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java create mode 100644 common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java create mode 100644 common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java create mode 100644 common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java create mode 100644 common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 81b56aff28..419e6d3dfd 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1394,6 +1394,15 @@ transport: branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.0.0}" # Fetch frequency in hours for gateways dashboard repository fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # CoAP server parameters coap: diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 271866d23c..7882125906 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardExecutors; +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; @@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S @Slf4j @Component @TbCoapServerComponent -public class DefaultCoapServerService implements CoapServerService { +public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton { @Autowired private CoapServerContext coapServerContext; private CoapServer server; - private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; + private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; private ScheduledExecutorService dtlsSessionsExecutor; + private volatile DTLSConnector dtlsConnector; + + private volatile CoapEndpoint dtlsCoapEndpoint; + @PostConstruct public void init() throws UnknownHostException { createCoapServer(); } + @Override + public void afterSingletonsInstantiated() { + if (isDtlsEnabled()) { + coapServerContext.getDtlsSettings().registerReloadCallback(() -> { + try { + log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint..."); + recreateDtlsEndpoint(); + log.info("CoAP DTLS endpoint recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e); + } + }); + } + } + @PreDestroy public void shutdown() { if (dtlsSessionsExecutor != null) { @@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService { } private CoapServer createCoapServer() throws UnknownHostException { - Configuration networkConfig = new Configuration(); - networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); - networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); - networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); - networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); - networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); - networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); - networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + Configuration networkConfig = createNetworkConfiguration(); server = new CoapServer(networkConfig); CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); @@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService { CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); server.addEndpoint(noSecCoapEndpoint); if (isDtlsEnabled()) { - CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); - TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); - DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); - networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); - dtlsCoapEndpointBuilder.setConfiguration(networkConfig); - DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig); - dtlsCoapEndpointBuilder.setConnector(connector); - CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build(); - server.addEndpoint(dtlsCoapEndpoint); - tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + createDtlsEndpoint(networkConfig); dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); } @@ -137,4 +140,88 @@ public class DefaultCoapServerService implements CoapServerService { return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout(); } + private Configuration createNetworkConfiguration() { + Configuration networkConfig = new Configuration(); + networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); + networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); + networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); + networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); + networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); + networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + return networkConfig; + } + + DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { + TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); + DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); + networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); + return dtlsConnectorConfig; + } + + CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { + CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); + dtlsCoapEndpointBuilder.setConfiguration(networkConfig); + dtlsCoapEndpointBuilder.setConnector(connector); + return dtlsCoapEndpointBuilder.build(); + } + + private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException { + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + server.addEndpoint(newEndpoint); + + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + } + + DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { + return new DTLSConnector(config); + } + + private synchronized void recreateDtlsEndpoint() throws IOException { + CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint; + DTLSConnector oldDtlsConnector = dtlsConnector; + + Configuration networkConfig = createNetworkConfiguration(); + + log.info("Creating new DTLS endpoint with updated certificates..."); + + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + + server.addEndpoint(newEndpoint); + try { + newEndpoint.start(); + } catch (IOException e) { + log.error("Failed to start new DTLS endpoint, cleaning up", e); + server.getEndpoints().remove(newEndpoint); + newEndpoint.destroy(); + newConnector.destroy(); + throw e; + } + log.info("New DTLS endpoint started successfully."); + + // Only swap instance fields after a successful start + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + + if (oldDtlsEndpoint != null) { + log.info("Stopping old DTLS endpoint..."); + oldDtlsEndpoint.stop(); + if (oldDtlsConnector != null) { + oldDtlsConnector.destroy(); + } + oldDtlsEndpoint.destroy(); + server.getEndpoints().remove(oldDtlsEndpoint); + log.info("Old DTLS endpoint stopped and destroyed."); + } + } + } diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java index 672de6bacd..c6e90d1351 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java @@ -100,6 +100,10 @@ public class TbCoapDtlsSettings { @Autowired(required = false) private TbServiceInfoProvider serviceInfoProvider; + public void registerReloadCallback(Runnable callback) { + coapDtlsCredentialsConfig.registerReloadCallback(callback); + } + public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException { DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration); configBuilder.setAddress(getInetSocketAddress()); @@ -154,5 +158,5 @@ public class TbCoapDtlsSettings { } return null; } -} +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java new file mode 100644 index 0000000000..642f2e0be9 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -0,0 +1,235 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.network.Endpoint; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class CoapDtlsCertificateReloadTest { + + @Mock + private CoapServerContext mockCoapServerContext; + + @Mock + private TbCoapDtlsSettings mockDtlsSettings; + + @Mock + private CoapServer mockCoapServer; + + @Mock + private CoapEndpoint mockDtlsEndpoint; + + @Mock + private DTLSConnector mockDtlsConnector; + + private DefaultCoapServerService coapServerService; + + @BeforeEach + public void setup() { + coapServerService = new DefaultCoapServerService(); + ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + doAnswer(invocation -> { + invocation.getArgument(0); + return null; + }).when(mockDtlsSettings).registerReloadCallback(any()); + } + + @Test + public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings, never()).registerReloadCallback(any()); + } + + @Test + public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + // dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw. + // The old endpoint should not be stopped/destroyed when creation of the new one fails. + reloadCallback.run(); + + verify(mockDtlsEndpoint, never()).stop(); + verify(mockDtlsConnector, never()).destroy(); + } + + @Test + public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class)); + } + + @Test + public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + assertThat(reloadCallback).isNotNull(); + } + + @Test + public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + DTLSConnector mockNewConnector = mock(DTLSConnector.class); + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); + + DefaultCoapServerService spyService = Mockito.spy(coapServerService); + ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); + ReflectionTestUtils.setField(spyService, "server", mockCoapServer); + ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); + + doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); + doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); + doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + + List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + // WHEN + ReflectionTestUtils.invokeMethod(spyService, "recreateDtlsEndpoint"); + + // THEN + assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).destroy(); + verify(mockDtlsConnector).destroy(); + verify(mockCoapServer).addEndpoint(mockNewEndpoint); + verify(mockNewEndpoint).start(); + assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + } + + @Test + public void givenReloadCallback_whenStartFails_thenNewResourcesCleaned() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + DTLSConnector mockNewConnector = mock(DTLSConnector.class); + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + + doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); + + DefaultCoapServerService spyService = Mockito.spy(coapServerService); + ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); + ReflectionTestUtils.setField(spyService, "server", mockCoapServer); + ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); + + doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); + doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); + doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + + List endpointsList = new CopyOnWriteArrayList<>(); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + // WHEN - the callback catches the IOException internally + spyService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + reloadCallback.run(); + + // THEN - new resources cleaned up + verify(mockNewEndpoint).destroy(); + verify(mockNewConnector).destroy(); + assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old fields preserved + assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); + assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); + // Old endpoint not touched + verify(mockDtlsEndpoint, never()).stop(); + verify(mockDtlsEndpoint, never()).destroy(); + } + +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java index c75348d97e..9538670e25 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java @@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; @@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest { @Autowired TbCoapDtlsSettings coapDtlsSettings; - @MockBean + @MockitoBean SslCredentialsConfig sslCredentialsConfig; - @MockBean + @MockitoBean private TransportService transportService; - @MockBean + @MockitoBean private TbServiceInfoProvider serviceInfoProvider; @Test diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java index 725aa7921c..626d0cd372 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java @@ -51,11 +51,9 @@ public class ResourceUtils { return true; } else { try { - URL url = Resources.getResource(path); - if (url != null) { - return true; - } - } catch (IllegalArgumentException e) {} + Resources.getResource(path); + return true; + } catch (IllegalArgumentException ignored) {} } return false; } @@ -93,9 +91,9 @@ public class ResourceUtils { } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } } throw new RuntimeException("Unable to find resource: " + filePath); @@ -113,15 +111,16 @@ public class ResourceUtils { return resourceFile.getAbsolutePath(); } else { URL url = classLoader.getResource(filePath); - return url.toURI().toString(); + return url != null ? url.toURI().toString() : null; } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } throw new RuntimeException("Unable to find resource: " + filePath); } } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java new file mode 100644 index 0000000000..8c91762068 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResourceUtilsTest { + + @Test + public void givenNonExistentResource_whenGetUri_thenReturnsNull() { + String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"); + + assertThat(result).isNull(); + } + + @Test + public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() { + String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class"); + + assertThat(result).isNotNull(); + assertThat(result).contains("ResourceUtilsTest"); + } + +} diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 9a7cfe43ff..73d77e2ddb 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -76,10 +76,6 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; - -/** - * @author Andrew Shvayka - */ @RestController @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @RequestMapping("/api/v1") diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 830e081dfd..db72ba747c 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -26,9 +26,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @Component @@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext { } }; } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 412036677a..78f292d69c 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -29,6 +29,7 @@ import org.eclipse.leshan.server.californium.bootstrap.LwM2mBootstrapPskStore; import org.eclipse.leshan.server.californium.bootstrap.endpoint.CaliforniumBootstrapServerEndpointsProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coap.CoapBootstrapServerProtocolProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coaps.CoapsBootstrapServerProtocolProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; @@ -55,7 +56,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @Component @TbLwM2mBootstrapTransportComponent @RequiredArgsConstructor -public class LwM2MTransportBootstrapService { +public class LwM2MTransportBootstrapService implements SmartInitializingSingleton { private final LwM2MTransportServerConfig serverConfig; private final LwM2MTransportBootstrapConfig bootstrapConfig; @@ -65,6 +66,19 @@ public class LwM2MTransportBootstrapService { private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; private LeshanBootstrapServer server; + @Override + public void afterSingletonsInstantiated() { + bootstrapConfig.registerServerReloadCallback(() -> { + try { + log.info("LwM2M Bootstrap certificates reloaded. Recreating bootstrap server..."); + recreateBootstrapServer(); + log.info("LwM2M Bootstrap server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M Bootstrap server after certificate reload", e); + } + }); + } + @PostConstruct public void init() { log.info("Starting LwM2M transport bootstrap server..."); @@ -110,7 +124,7 @@ public class LwM2MTransportBootstrapService { // Create Californium Configuration Configuration serverCoapConfig = endpointsBuilder.createDefaultConfiguration(); - getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(),serverConfig); + getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(), serverConfig); serverCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY); serverCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, serverConfig.isRecommendedCiphers()); serverCoapConfig.setTransient(DtlsConfig.DTLS_CONNECTION_ID_LENGTH); @@ -119,7 +133,7 @@ public class LwM2MTransportBootstrapService { serverCoapConfig.set(DTLS_RETRANSMISSION_TIMEOUT, serverConfig.getDtlsRetransmissionTimeout(), MILLISECONDS); if (serverConfig.getDtlsCidLength() != null) { - setDtlsConnectorConfigCidLength( serverCoapConfig, serverConfig.getDtlsCidLength()); + setDtlsConnectorConfigCidLength(serverCoapConfig, serverConfig.getDtlsCidLength()); } /* Create DTLS Config */ @@ -164,4 +178,27 @@ public class LwM2MTransportBootstrapService { builder.setTrustedCertificates(new X509Certificate[0]); } } + + private synchronized void recreateBootstrapServer() { + LeshanBootstrapServer oldServer = this.server; + + log.info("Creating new LwM2M Bootstrap server with updated certificates..."); + LeshanBootstrapServer newServer = getLhBootstrapServer(); + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M Bootstrap server, rolling back", e); + newServer.destroy(); + throw e; + } + this.server = newServer; + log.info("New LwM2M Bootstrap server started successfully."); + + if (oldServer != null) { + log.info("Stopping old LwM2M Bootstrap server..."); + oldServer.destroy(); + log.info("Old LwM2M Bootstrap server stopped."); + } + } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java index b15a757ae3..d3bf9e33fe 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +28,9 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Component @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") @@ -62,8 +66,33 @@ public class LwM2MTransportBootstrapConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mBootstrapCredentials") private SslCredentialsConfig credentialsConfig; + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Bootstrap DTLS certificates reloaded. Triggering bootstrap server reload..."); + notifyServerReload(); + }); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M bootstrap server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java index d2b24fe9a8..6034219c4d 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -31,6 +32,7 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; @Slf4j @Component @@ -134,6 +136,35 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mTrustCredentials") private SslCredentialsConfig trustCredentialsConfig; + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Server DTLS certificates reloaded. Triggering server reload..."); + notifyServerReload(); + }); + + trustCredentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Trust certificates reloaded. Triggering server reload..."); + notifyServerReload(); + }); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); @@ -142,4 +173,5 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { public SslCredentials getTrustSslCredentials() { return this.trustCredentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index c4fb504864..21fdc07277 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -33,6 +33,7 @@ import org.eclipse.leshan.server.californium.endpoint.CaliforniumServerEndpoints import org.eclipse.leshan.server.californium.endpoint.coap.CoapServerProtocolProvider; import org.eclipse.leshan.server.californium.endpoint.coaps.CoapsServerProtocolProvider; import org.eclipse.leshan.server.registration.RegistrationStore; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Component; import org.thingsboard.server.cache.ota.OtaPackageDataCache; @@ -68,7 +69,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @DependsOn({"lwM2mDownlinkMsgHandler", "lwM2mUplinkMsgHandler"}) @TbLwM2mTransportComponent @RequiredArgsConstructor -public class DefaultLwM2mTransportService implements LwM2MTransportService { +public class DefaultLwM2mTransportService implements LwM2MTransportService, SmartInitializingSingleton { public static final CipherSuite[] RPK_OR_X509_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256}; public static final CipherSuite[] PSK_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256}; @@ -84,6 +85,20 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private final LwM2mVersionedModelProvider modelProvider; private LeshanServer server; + private LwM2mServerListener serverListener; + + @Override + public void afterSingletonsInstantiated() { + config.registerServerReloadCallback(() -> { + try { + log.info("LwM2M certificates reloaded. Recreating LwM2M server..."); + recreateLwM2mServer(); + log.info("LwM2M server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M server after certificate reload", e); + } + }); + } @AfterStartUp(order = AfterStartUp.AFTER_TRANSPORT_SERVICE) public void init() { @@ -95,11 +110,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private void startLhServer() { log.info("Starting LwM2M transport server..."); this.server.start(); - LwM2mServerListener lhServerCertListener = new LwM2mServerListener(handler); - this.server.getRegistrationService().addListener(lhServerCertListener.registrationListener); - this.server.getPresenceService().addListener(lhServerCertListener.presenceListener); - this.server.getObservationService().addListener(lhServerCertListener.observationListener); - this.server.getSendService().addListener(lhServerCertListener.sendListener); + serverListener = new LwM2mServerListener(handler); + this.server.getRegistrationService().addListener(serverListener.registrationListener); + this.server.getPresenceService().addListener(serverListener.presenceListener); + this.server.getObservationService().addListener(serverListener.observationListener); + this.server.getSendService().addListener(serverListener.sendListener); log.info("Started LwM2M transport server."); } @@ -214,6 +229,44 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { } } + private synchronized void recreateLwM2mServer() { + LeshanServer oldServer = this.server; + LwM2mServerListener oldListener = this.serverListener; + + log.info("Creating new LwM2M server with updated certificates..."); + LeshanServer newServer = getLhServer(); + newServer.start(); + + try { + LwM2mServerListener newListener = new LwM2mServerListener(handler); + newServer.getRegistrationService().addListener(newListener.registrationListener); + newServer.getPresenceService().addListener(newListener.presenceListener); + newServer.getObservationService().addListener(newListener.observationListener); + newServer.getSendService().addListener(newListener.sendListener); + + this.server = newServer; + this.context.setServer(newServer); + this.serverListener = newListener; + } catch (Exception e) { + log.error("Failed to register listeners on new LwM2M server, rolling back", e); + newServer.destroy(); + throw e; + } + log.info("New LwM2M server started successfully."); + + if (oldServer != null) { + log.info("Stopping old LwM2M server..."); + if (oldListener != null) { + oldServer.getRegistrationService().removeListener(oldListener.registrationListener); + oldServer.getPresenceService().removeListener(oldListener.presenceListener); + oldServer.getObservationService().removeListener(oldListener.observationListener); + oldServer.getSendService().removeListener(oldListener.sendListener); + } + oldServer.destroy(); + log.info("Old LwM2M server stopped."); + } + } + @Override public String getName() { return DataConstants.LWM2M_TRANSPORT_NAME; diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java new file mode 100644 index 0000000000..51d37ca739 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -0,0 +1,192 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.bootstrap; + +import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mBootstrapCertificateReloadTest { + + @Mock + private LwM2MTransportServerConfig mockServerConfig; + + @Mock + private LwM2MTransportBootstrapConfig mockBootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore mockSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore mockConfigStore; + + @Mock + private TransportService mockTransportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier; + + @Mock + private LeshanBootstrapServer mockBootstrapServer; + + @Mock + private SslCredentials mockSslCredentials; + + private LwM2MTransportBootstrapService bootstrapService; + + @BeforeEach + public void setup() { + bootstrapService = new LwM2MTransportBootstrapService( + mockServerConfig, + mockBootstrapConfig, + mockSecurityStore, + mockConfigStore, + mockTransportService, + mockCertificateVerifier + ); + + when(mockBootstrapConfig.getHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getPort()).thenReturn(5687); + when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getSecurePort()).thenReturn(5688); + when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials); + when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + } + + @Test + public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + bootstrapService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + // Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockBootstrapConfig.getHost()).thenReturn(null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // getLhBootstrapServer() will fail due to null host. + // With create-then-swap, the old server should NOT be destroyed. + reloadCallback.run(); + + verify(mockBootstrapServer, never()).destroy(); + assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); + } + + @Test + public void givenNullServer_whenRecreate_thenShouldNotThrow() { + ReflectionTestUtils.setField(bootstrapService, "server", null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // Should not throw — callback catches exceptions internally + reloadCallback.run(); + } + + @Test + public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() { + SslCredentials oldCredentials = mockSslCredentials; + SslCredentials newCredentials = mock(SslCredentials.class); + + when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials); + + SslCredentials firstCall = mockBootstrapConfig.getSslCredentials(); + assertThat(firstCall).isEqualTo(oldCredentials); + + SslCredentials secondCall = mockBootstrapConfig.getSslCredentials(); + assertThat(secondCall).isEqualTo(newCredentials); + + verify(mockBootstrapConfig, times(2)).getSslCredentials(); + } + + @Test + public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() { + bootstrapService.afterSingletonsInstantiated(); + + verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndOldPreserved() { + // GIVEN + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); + doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); + + LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); + doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + spyService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // WHEN + reloadCallback.run(); + + // THEN + verify(mockNewServer).destroy(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); + verify(mockBootstrapServer, never()).destroy(); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java new file mode 100644 index 0000000000..15e4d07622 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -0,0 +1,188 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server; + +import org.eclipse.leshan.server.LeshanServer; +import org.eclipse.leshan.server.observation.ObservationService; +import org.eclipse.leshan.server.registration.RegistrationService; +import org.eclipse.leshan.server.registration.RegistrationStore; +import org.eclipse.leshan.server.send.SendService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore; +import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mServerCertificateReloadTest { + + @Mock + private LwM2mTransportContext mockContext; + + @Mock + private LwM2MTransportServerConfig mockConfig; + + @Mock + private OtaPackageDataCache mockOtaCache; + + @Mock + private LwM2mUplinkMsgHandler mockHandler; + + @Mock + private RegistrationStore mockRegistrationStore; + + @Mock + private TbSecurityStore mockSecurityStore; + + @Mock + private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier; + + @Mock + private TbLwM2MAuthorizer mockAuthorizer; + + @Mock + private LwM2mVersionedModelProvider mockModelProvider; + + @Mock + private LeshanServer mockLeshanServer; + + @Mock + private RegistrationService mockRegistrationService; + + @Mock + private ObservationService mockObservationService; + + @Mock + private SendService mockSendService; + + @Mock + private SslCredentials mockSslCredentials; + + private DefaultLwM2mTransportService lwm2mTransportService; + + @BeforeEach + public void setup() { + lwm2mTransportService = new DefaultLwM2mTransportService( + mockContext, + mockConfig, + mockOtaCache, + mockHandler, + mockRegistrationStore, + mockSecurityStore, + mockCertificateVerifier, + mockAuthorizer, + mockModelProvider + ); + + when(mockConfig.getHost()).thenReturn("localhost"); + when(mockConfig.getPort()).thenReturn(5683); + when(mockConfig.getSecureHost()).thenReturn("localhost"); + when(mockConfig.getSecurePort()).thenReturn(5684); + when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials); + + when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService); + when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService); + when(mockLeshanServer.getSendService()).thenReturn(mockSendService); + } + + @Test + public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + // Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockConfig.getHost()).thenReturn(null); + + // With create-then-swap, the old server should NOT be destroyed if the new one fails. + reloadCallback.run(); + + verify(mockLeshanServer, never()).destroy(); + // Old server should still be the active one + assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); + } + + @Test + public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); + ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); + + // Invoke the callback — new server creation will fail, old listeners should stay + callbackCaptor.getValue().run(); + + verify(mockRegistrationService, never()).removeListener(any()); + } + + @Test + public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() { + lwm2mTransportService.afterSingletonsInstantiated(); + + verify(mockConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenCertificateReload_whenServerNull_thenShouldNotThrow() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", null); + + // Should not throw - callback catches exceptions internally + callbackCaptor.getValue().run(); + } + +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 1551ef9023..fcb58bc20c 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -48,7 +49,7 @@ import java.util.concurrent.TimeUnit; @Slf4j @Component("MqttSslHandlerProvider") @TbMqttSslTransportComponent -public class MqttSslHandlerProvider { +public class MqttSslHandlerProvider implements SmartInitializingSingleton { @Value("${transport.mqtt.ssl.protocol}") private String sslProtocol; @@ -66,13 +67,29 @@ public class MqttSslHandlerProvider { @Qualifier("mqttSslCredentials") private SslCredentialsConfig mqttSslCredentialsConfig; - private SSLContext sslContext; + private volatile SSLContext sslContext; + + @Override + public void afterSingletonsInstantiated() { + mqttSslCredentialsConfig.registerReloadCallback(() -> { + log.info("MQTT SSL certificates reloaded. Invalidating SSL context..."); + sslContext = null; + log.info("MQTT SSL context invalidated. Will be recreated on next connection."); + }); + } public SslHandler getSslHandler() { - if (sslContext == null) { - sslContext = createSslContext(); + SSLContext ctx = sslContext; + if (ctx == null) { + synchronized (this) { + ctx = sslContext; + if (ctx == null) { + ctx = createSslContext(); + sslContext = ctx; + } + } } - SSLEngine sslEngine = sslContext.createSSLEngine(); + SSLEngine sslEngine = ctx.createSSLEngine(); sslEngine.setUseClientMode(false); sslEngine.setNeedClientAuth(false); sslEngine.setWantClientAuth(true); @@ -98,7 +115,7 @@ public class MqttSslHandlerProvider { sslContext.init(km, tm, null); return sslContext; } catch (Exception e) { - log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); + log.error("Unable to set up SSL context. Reason: {}", e.getMessage(), e); throw new RuntimeException("Failed to get SSL context", e); } } @@ -106,8 +123,8 @@ public class MqttSslHandlerProvider { private TrustManager getX509TrustManager(TrustManagerFactory tmf) throws Exception { X509TrustManager x509Tm = null; for (TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof X509TrustManager) { - x509Tm = (X509TrustManager) tm; + if (tm instanceof X509TrustManager x509TrustManager) { + x509Tm = x509TrustManager; break; } } @@ -191,5 +208,7 @@ public class MqttSslHandlerProvider { return false; } } + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 3d05e999e0..166a67368c 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -32,9 +32,6 @@ import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @Component @TbMqttTransportComponent diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java new file mode 100644 index 0000000000..96183c2934 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java @@ -0,0 +1,197 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import io.netty.handler.ssl.SslHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MqttSslHandlerProviderTest { + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private TransportService mockTransportService; + + private MqttSslHandlerProvider sslHandlerProvider; + + @BeforeEach + public void setup() throws Exception { + sslHandlerProvider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig); + ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService); + ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2"); + + KeyManagerFactory mockKmf = mock(KeyManagerFactory.class); + TrustManagerFactory mockTmf = mock(TrustManagerFactory.class); + X509TrustManager mockTrustManager = mock(X509TrustManager.class); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf); + when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf); + when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]); + when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager}); + } + + @Test + public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + SslHandler handler1 = sslHandlerProvider.getSslHandler(); + SslHandler handler2 = sslHandlerProvider.getSslHandler(); + + assertThat(handler1).isNotNull(); + assertThat(handler2).isNotNull(); + assertThat(handler1).isNotSameAs(handler2); + + SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context).isNotNull(); + } + + @Test + public void givenCertificatesReloaded_whenGetSslHandler_thenShouldRecreateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SslHandler handler1 = sslHandlerProvider.getSslHandler(); + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + reloadCallback.run(); + + assertThat(handler1).isNotNull(); + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNull(); + + SslHandler handler2 = sslHandlerProvider.getSslHandler(); + SSLContext newContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + + assertThat(handler2).isNotNull(); + assertThat(newContext).isNotNull(); + assertThat(newContext).isNotSameAs(initialContext); + } + + @Test + public void givenConcurrentGetSslHandlerCalls_whenSSLContextNull_thenShouldCreateOnlyOnce() throws Exception { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + SslHandler handler = sslHandlerProvider.getSslHandler(); + assertThat(handler).isNotNull(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context).isNotNull(); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldInvalidateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + sslHandlerProvider.getSslHandler(); + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNull(); + } + + @Test + public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SSLContext context1; + SSLContext context2; + SSLContext context3; + + sslHandlerProvider.getSslHandler(); + context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context1).isNotNull(); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context2).isNotNull(); + assertThat(context2).isNotSameAs(context1); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context3).isNotNull(); + assertThat(context3).isNotSameAs(context2); + assertThat(context3).isNotSameAs(context1); + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java index df30e2879b..c595e3ca83 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java @@ -19,9 +19,13 @@ import lombok.Getter; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.queue.discovery.event.TbApplicationEvent; +import java.io.Serial; + public final class DeviceDeletedEvent extends TbApplicationEvent { + @Serial private static final long serialVersionUID = -7453664970966733857L; + @Getter private final DeviceId deviceId; @@ -29,4 +33,5 @@ public final class DeviceDeletedEvent extends TbApplicationEvent { super(new Object()); this.deviceId = deviceId; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 1e03156ec8..12857e3208 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMs import java.util.Optional; import java.util.UUID; -/** - * Created by ashvayka on 04.10.18. - */ public interface SessionMsgListener { void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index 9317652719..afd7bd2fed 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.queue.scheduler.SchedulerComponent; import java.util.concurrent.ExecutorService; -/** - * Created by ashvayka on 15.10.18. - */ @Slf4j @Data public abstract class TransportContext { @@ -77,6 +74,4 @@ public abstract class TransportContext { return serviceInfoProvider.getServiceId(); } - - } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 5c7d552855..939f2278ce 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -66,9 +66,6 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportService { GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg); @@ -162,4 +159,5 @@ public interface TransportService { boolean hasSession(SessionInfoProto sessionInfo); void createGaugeStats(String openConnections, AtomicInteger connectionsCounter); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java index 41cb57da04..82849b5bdd 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.transport; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportServiceCallback { TransportServiceCallback EMPTY = new TransportServiceCallback() { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java index f15fc42364..9ffa4f7aac 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java @@ -37,41 +37,56 @@ import java.util.Enumeration; import java.util.HashSet; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; public abstract class AbstractSslCredentials implements SslCredentials { - private char[] keyPasswordArray; + private record SslState( + char[] keyPasswordArray, + KeyStore keyStore, + PrivateKey privateKey, + PublicKey publicKey, + X509Certificate[] chain, + X509Certificate[] trusts + ) {} - private KeyStore keyStore; - - private PrivateKey privateKey; - - private PublicKey publicKey; - - private X509Certificate[] chain; - - private X509Certificate[] trusts; + private final AtomicReference state = new AtomicReference<>(); @Override public void init(boolean trustsOnly) throws IOException, GeneralSecurityException { + SslState newState = buildState(trustsOnly); + state.set(newState); + } + + @Override + public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException { + SslState newState = buildState(trustsOnly); + state.set(newState); + } + + private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException { String keyPassword = getKeyPassword(); + char[] keyPasswordArray; if (StringUtils.isEmpty(keyPassword)) { - this.keyPasswordArray = new char[0]; + keyPasswordArray = new char[0]; } else { - this.keyPasswordArray = keyPassword.toCharArray(); + keyPasswordArray = keyPassword.toCharArray(); } - this.keyStore = this.loadKeyStore(trustsOnly, this.keyPasswordArray); - Set trustedCerts = getTrustedCerts(this.keyStore, trustsOnly); - this.trusts = trustedCerts.toArray(new X509Certificate[0]); + KeyStore keyStore = this.loadKeyStore(trustsOnly, keyPasswordArray); + Set trustedCerts = getTrustedCerts(keyStore, trustsOnly); + X509Certificate[] trusts = trustedCerts.toArray(new X509Certificate[0]); + PrivateKey privateKey = null; + PublicKey publicKey = null; + X509Certificate[] chain = null; if (!trustsOnly) { PrivateKeyEntry privateKeyEntry = null; String keyAlias = this.getKeyAlias(); if (!StringUtils.isEmpty(keyAlias)) { - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, keyAlias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, keyAlias, keyPasswordArray); } else { - for (Enumeration e = this.keyStore.aliases(); e.hasMoreElements(); ) { + for (Enumeration e = keyStore.aliases(); e.hasMoreElements(); ) { String alias = e.nextElement(); - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, alias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, alias, keyPasswordArray); if (privateKeyEntry != null) { this.updateKeyAlias(alias); break; @@ -82,50 +97,61 @@ public abstract class AbstractSslCredentials implements SslCredentials { throw new IllegalArgumentException("Failed to get private key from the keystore or pem files. " + "Please check if the private key exists in the keystore or pem files and if the provided private key password is valid."); } - this.chain = asX509Certificates(privateKeyEntry.getCertificateChain()); - this.privateKey = privateKeyEntry.getPrivateKey(); - if (this.chain.length > 0) { - this.publicKey = this.chain[0].getPublicKey(); + chain = asX509Certificates(privateKeyEntry.getCertificateChain()); + privateKey = privateKeyEntry.getPrivateKey(); + if (chain.length > 0) { + publicKey = chain[0].getPublicKey(); } } + return new SslState(keyPasswordArray, keyStore, privateKey, publicKey, chain, trusts); + } + + private SslState getState() { + SslState s = state.get(); + if (s == null) { + throw new IllegalStateException("SSL credentials not initialized. Call init() first."); + } + return s; } @Override public KeyStore getKeyStore() { - return this.keyStore; + return getState().keyStore; } @Override public PrivateKey getPrivateKey() { - return this.privateKey; + return getState().privateKey; } @Override public PublicKey getPublicKey() { - return this.publicKey; + return getState().publicKey; } @Override public X509Certificate[] getCertificateChain() { - return this.chain; + return getState().chain; } @Override public X509Certificate[] getTrustedCertificates() { - return this.trusts; + return getState().trusts; } @Override public TrustManagerFactory createTrustManagerFactory() throws NoSuchAlgorithmException, KeyStoreException { + SslState s = getState(); TrustManagerFactory tmFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmFactory.init(this.keyStore); + tmFactory.init(s.keyStore); return tmFactory; } @Override public KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException { + SslState s = getState(); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(this.keyStore, this.keyPasswordArray); + kmf.init(s.keyStore, s.keyPasswordArray); return kmf; } @@ -133,7 +159,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { public String getValueFromSubjectNameByKey(String subjectName, String key) { String[] dns = subjectName.split(","); Optional cn = (Arrays.stream(dns).filter(dn -> dn.contains(key + "="))).findFirst(); - String value = cn.isPresent() ? cn.get().replace(key + "=", "") : null; + String value = cn.map(s -> s.replace(key + "=", "")).orElse(null); return StringUtils.isNotEmpty(value) ? value : null; } @@ -189,7 +215,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (cert instanceof X509Certificate) { if (trustsOnly) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } else { @@ -203,12 +229,12 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (trustsOnly) { for (Certificate cert : certs) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } } else { - set.add((X509Certificate)certs[0]); + set.add((X509Certificate) certs[0]); } } } @@ -216,4 +242,5 @@ public abstract class AbstractSslCredentials implements SslCredentials { } catch (KeyStoreException ignored) {} return Collections.unmodifiableSet(set); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 33851f50b1..7a2fb1a545 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -20,10 +20,14 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.util.Collections; +import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @@ -54,4 +58,16 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { protected void updateKeyAlias(String keyAlias) { this.keyAlias = keyAlias; } + + @Override + public List getCertificateFilePaths() { + if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + File storeFileObj = new File(storeFile); + if (storeFileObj.exists()) { + return Collections.singletonList(storeFileObj.toPath().toAbsolutePath()); + } + } + return Collections.emptyList(); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index cb2c9ba97b..72ad7af9c5 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -30,9 +30,11 @@ import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; @@ -76,13 +78,13 @@ public class PemSslCredentials extends AbstractSslCredentials { if (object instanceof X509CertificateHolder) { X509Certificate x509Cert = certConverter.getCertificate((X509CertificateHolder) object); certificates.add(x509Cert); - } else if (object instanceof PEMEncryptedKeyPair) { + } else if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -93,15 +95,15 @@ public class PemSslCredentials extends AbstractSslCredentials { try (PEMParser pemParser = new PEMParser(new InputStreamReader(inStream))) { Object object; while ((object = pemParser.readObject()) != null) { - if (object instanceof PEMEncryptedKeyPair) { + if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); break; - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); break; - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -138,6 +140,27 @@ public class PemSslCredentials extends AbstractSslCredentials { } @Override - protected void updateKeyAlias(String keyAlias) { + protected void updateKeyAlias(String keyAlias) {} + + @Override + public List getCertificateFilePaths() { + List paths = new ArrayList<>(); + + if (!StringUtils.isEmpty(certFile) && !certFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + File certFileObj = new File(certFile); + if (certFileObj.exists()) { + paths.add(certFileObj.toPath().toAbsolutePath()); + } + } + + if (!StringUtils.isEmpty(keyFile) && !keyFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + File keyFileObj = new File(keyFile); + if (keyFileObj.exists()) { + paths.add(keyFileObj.toPath().toAbsolutePath()); + } + } + + return paths; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java index 89d4540b77..89dccac18e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.transport.config.ssl; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.TrustManagerFactory; import java.io.IOException; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -26,11 +27,14 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.security.UnrecoverableKeyException; import java.security.cert.X509Certificate; +import java.util.List; public interface SslCredentials { void init(boolean trustsOnly) throws IOException, GeneralSecurityException; + void reload(boolean trustsOnly) throws IOException, GeneralSecurityException; + KeyStore getKeyStore(); String getKeyPassword(); @@ -50,4 +54,7 @@ public interface SslCredentials { KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException; String getValueFromSubjectNameByKey(String subjectName, String key); + + List getCertificateFilePaths(); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java index 0df22a33cb..3646c4f37d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java @@ -19,6 +19,9 @@ import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Data public class SslCredentialsConfig { @@ -33,6 +36,8 @@ public class SslCredentialsConfig { private final String name; private final boolean trustsOnly; + private final List reloadCallbacks = new CopyOnWriteArrayList<>(); + public SslCredentialsConfig(String name, boolean trustsOnly) { this.name = name; this.trustsOnly = trustsOnly; @@ -62,4 +67,26 @@ public class SslCredentialsConfig { } } + public void onCertificateFileChanged() { + try { + log.info("{}: Certificate file changed. Reloading SSL credentials...", name); + this.credentials.reload(this.trustsOnly); + log.info("{}: SSL credentials reloaded successfully.", name); + + for (Runnable callback : reloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("{}: Error executing reload callback", name, e); + } + } + } catch (Exception e) { + log.error("{}: Failed to reload SSL credentials", name, e); + } + } + + public void registerReloadCallback(Runnable callback) { + this.reloadCallbacks.add(callback); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index 34cc1151c4..147daefa7e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.transport.config.ssl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; @@ -30,71 +32,124 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.BiConsumer; import java.util.function.Consumer; +@Slf4j @Component @ConditionalOnExpression("'${spring.main.web-environment:true}'=='true' && '${server.ssl.enabled:false}'=='true'") -public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer { +public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer, SmartInitializingSingleton { - @Bean - @ConfigurationProperties(prefix = "server.ssl.credentials") - public SslCredentialsConfig httpServerSslCredentials() { - return new SslCredentialsConfig("HTTP Server SSL Credentials", false); - } + private static final String DEFAULT_BUNDLE_NAME = "default"; + + private final ServerProperties serverProperties; + private final List> updateHandlers = new CopyOnWriteArrayList<>(); @Autowired @Qualifier("httpServerSslCredentials") private SslCredentialsConfig httpServerSslCredentialsConfig; @Autowired - SslBundles sslBundles; - - private final ServerProperties serverProperties; + private SslBundles sslBundles; public SslCredentialsWebServerCustomizer(ServerProperties serverProperties) { this.serverProperties = serverProperties; } + @Bean + @ConfigurationProperties(prefix = "server.ssl.credentials") + public SslCredentialsConfig httpServerSslCredentials() { + return new SslCredentialsConfig("HTTP Server SSL Credentials", false); + } + + @Bean + public SslBundles sslBundles() { + return new DynamicSslBundles(); + } + @Override public void customize(ConfigurableServletWebServerFactory factory) { - SslCredentials sslCredentials = this.httpServerSslCredentialsConfig.getCredentials(); + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + Ssl ssl = serverProperties.getSsl(); - ssl.setBundle("default"); - ssl.setKeyAlias(sslCredentials.getKeyAlias()); - ssl.setKeyPassword(sslCredentials.getKeyPassword()); + ssl.setBundle(DEFAULT_BUNDLE_NAME); + ssl.setKeyAlias(credentials.getKeyAlias()); + ssl.setKeyPassword(credentials.getKeyPassword()); + factory.setSsl(ssl); factory.setSslBundles(sslBundles); } - @Bean - public SslBundles sslBundles() { + @Override + public void afterSingletonsInstantiated() { + httpServerSslCredentialsConfig.registerReloadCallback(this::reloadSslCertificates); + } + + private void reloadSslCertificates() { + try { + log.info("Reloading HTTP Server SSL certificates..."); + + SslBundle newBundle = createSslBundle(); + notifyUpdateHandlers(newBundle); + + log.info("HTTP Server SSL certificates reloaded successfully"); + } catch (Exception e) { + log.error("Failed to reload HTTP Server SSL certificates", e); + } + } + + private SslBundle createSslBundle() { + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + SslStoreBundle storeBundle = SslStoreBundle.of( - httpServerSslCredentialsConfig.getCredentials().getKeyStore(), - httpServerSslCredentialsConfig.getCredentials().getKeyPassword(), + credentials.getKeyStore(), + credentials.getKeyPassword(), null ); - return new SslBundles() { - @Override - public SslBundle getBundle(String name) { - return SslBundle.of(storeBundle); - } + return SslBundle.of(storeBundle); + } - @Override - public List getBundleNames() { - return List.of("default"); + private void notifyUpdateHandlers(SslBundle newBundle) { + for (Consumer handler : updateHandlers) { + try { + handler.accept(newBundle); + } catch (Exception e) { + log.error("Failed to notify SSL bundle update handler", e); } + } + } + + private class DynamicSslBundles implements SslBundles { - @Override - public void addBundleUpdateHandler(String name, Consumer handler) { - // no-op + @Override + public SslBundle getBundle(String name) { + if (!DEFAULT_BUNDLE_NAME.equals(name)) { + throw new IllegalArgumentException("Unknown SSL bundle: " + name); } + return createSslBundle(); + } - @Override - public void addBundleRegisterHandler(BiConsumer handler) { - // no-op + @Override + public List getBundleNames() { + return List.of(DEFAULT_BUNDLE_NAME); + } + + @Override + public void addBundleUpdateHandler(String name, Consumer handler) { + if (DEFAULT_BUNDLE_NAME.equals(name)) { + updateHandlers.add(handler); + log.debug("Registered SSL bundle update handler for bundle: {}", name); + } else { + log.warn("Attempted to register update handler for unknown bundle: {}", name); } - }; + } + + @Override + public void addBundleRegisterHandler(BiConsumer registerHandler) { + + } + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java new file mode 100644 index 0000000000..19fc545d0b --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -0,0 +1,272 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.queue.util.TbTransportComponent; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@TbTransportComponent +public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean { + + private static final int MAX_CONSECUTIVE_FAILURES = 10; + + @Value("${transport.ssl.certificate.reload.enabled:true}") + private boolean reloadEnabled; + + @Value("${transport.ssl.certificate.reload.check_interval:60}") + private long checkIntervalInSeconds; + + @Autowired + protected ApplicationContext applicationContext; + + private final Map watchers = new ConcurrentHashMap<>(); + private volatile ScheduledExecutorService scheduler; + + public void registerWatcher(String name, Path certPath, Runnable reloadCallback) { + registerWatcher(name, List.of(certPath), reloadCallback); + } + + public void registerWatcher(String name, List certPaths, Runnable reloadCallback) { + watchers.put(name, new CertificateWatcher(certPaths, reloadCallback)); + log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size()); + } + + private void checkCertificates() { + watchers.forEach((name, watcher) -> { + try { + watcher.checkAndReload(name); + } catch (Exception e) { + log.error("Error checking certificate for {}: {}", name, e.getMessage(), e); + } + }); + } + + private void discoverAndRegisterSslCredentials() { + try { + Map sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class); + + log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size()); + + for (Map.Entry entry : sslConfigBeans.entrySet()) { + String beanName = entry.getKey(); + SslCredentialsConfig config = entry.getValue(); + + try { + if (!config.isEnabled()) { + log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName); + continue; + } + + SslCredentials credentials = config.getCredentials(); + if (credentials == null) { + log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName); + continue; + } + + List filePaths = credentials.getCertificateFilePaths(); + if (filePaths == null || filePaths.isEmpty()) { + log.debug("No certificate files to watch for: {} ({})", config.getName(), beanName); + continue; + } + + List existingPaths = filePaths.stream() + .filter(p -> p != null && Files.exists(p)) + .toList(); + + for (Path filePath : filePaths) { + if (filePath == null || !Files.exists(filePath)) { + log.warn("Certificate file does not exist: {} (from {})", filePath, config.getName()); + } + } + + if (!existingPaths.isEmpty()) { + registerWatcher(config.getName(), existingPaths, config::onCertificateFileChanged); + log.info("Registered certificate watcher: {} -> {}", config.getName(), existingPaths); + } + + } catch (Exception e) { + log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e); + } + } + + } catch (Exception e) { + log.error("Error discovering SSL credentials configs", e); + } + } + + @Override + public void destroy() throws Exception { + if (scheduler != null) { + scheduler.shutdown(); + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } + } + + @Override + public void afterSingletonsInstantiated() { + if (!reloadEnabled) { + log.trace("Auto-reload of certificates is disabled. Skipping initialization..."); + return; + } + log.info("Initializing Certificate Reload Manager..."); + + discoverAndRegisterSslCredentials(); + + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager")); + scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS); + } + + static class CertificateWatcher { + private final List paths; + private final Runnable reloadCallback; + private final Map lastModifiedMap; + private final Map lastChecksumMap; + private int consecutiveFailures; + private String failedCombinedChecksum; + + CertificateWatcher(List paths, Runnable reloadCallback) { + this.paths = paths; + this.reloadCallback = reloadCallback; + this.lastModifiedMap = new HashMap<>(); + this.lastChecksumMap = new HashMap<>(); + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + lastChecksumMap.put(path, calculateChecksum(path)); + } + this.consecutiveFailures = 0; + } + + synchronized void checkAndReload(String name) { + boolean anyModifiedChanged = false; + for (Path path : paths) { + long currentModified = getLastModifiedTime(path); + Long lastModified = lastModifiedMap.getOrDefault(path, 0L); + if (currentModified != lastModified) { + anyModifiedChanged = true; + break; + } + } + if (!anyModifiedChanged) { + return; + } + + // Compute combined checksum of all files + Map currentChecksums = new HashMap<>(); + StringBuilder combined = new StringBuilder(); + for (Path path : paths) { + String checksum = calculateChecksum(path); + currentChecksums.put(path, checksum); + combined.append(checksum); + } + String combinedChecksum = combined.toString(); + + // Build old combined checksum for comparison + StringBuilder oldCombined = new StringBuilder(); + for (Path path : paths) { + oldCombined.append(lastChecksumMap.getOrDefault(path, "")); + } + String oldCombinedChecksum = oldCombined.toString(); + + if (combinedChecksum.equals(oldCombinedChecksum)) { + // Content unchanged, just update modification times + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + } + return; + } + + if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { + // File content changed since last failure — reset and retry + consecutiveFailures = 0; + failedCombinedChecksum = null; + } + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + return; + } + + try { + log.info("Certificate change detected for: {}. Triggering reload...", name); + reloadCallback.run(); + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + lastChecksumMap.put(path, currentChecksums.get(path)); + } + consecutiveFailures = 0; + failedCombinedChecksum = null; + } catch (Exception e) { + consecutiveFailures++; + failedCombinedChecksum = combinedChecksum; + log.error("Failed to reload certificate for {} (attempt {}/{}): {}", + name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); + } + } + + private long getLastModifiedTime(Path path) { + try { + if (!Files.exists(path)) { + return 0; + } + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return 0; + } + } + + private String calculateChecksum(Path path) { + try { + if (!Files.exists(path)) { + return ""; + } + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] bytes = Files.readAllBytes(path); + byte[] hash = md.digest(bytes); + return Base64.getEncoder().encodeToString(hash); + } catch (Exception e) { + log.warn("Failed to calculate checksum for certificate file: {}", path, e); + return ""; + } + } + + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 260957c990..240de91424 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -127,9 +127,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -/** - * Created by ashvayka on 17.10.18. - */ @Slf4j @Service @TbTransportComponent @@ -789,7 +786,7 @@ public class DefaultTransportService extends TransportActivityManager implements TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build(); - ScheduledFuture executorFuture = scheduler.schedule(() -> { + ScheduledFuture executorFuture = scheduler.schedule(() -> { listener.onRemoteSessionCloseCommand(sessionId, notification); deregisterSession(sessionInfo); }, timeout, TimeUnit.MILLISECONDS); @@ -1169,6 +1166,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private static class StatsCallback implements TbQueueCallback { @@ -1183,16 +1181,19 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void onSuccess(TbQueueMsgMetadata metadata) { stats.incrementSuccessful(); - if (callback != null) + if (callback != null) { callback.onSuccess(metadata); + } } @Override public void onFailure(Throwable t) { stats.incrementFailed(); - if (callback != null) + if (callback != null) { callback.onFailure(t); + } } + } private class MsgPackCallback implements TbQueueCallback { @@ -1215,6 +1216,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private class ApiStatsProxyCallback implements TransportServiceCallback { @@ -1244,6 +1246,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onError(Throwable e) { callback.onError(e); } + } @Override @@ -1270,4 +1273,5 @@ public class DefaultTransportService extends TransportActivityManager implements log.info("Transport Stats: {}", values); } } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java index 245f167ef8..612871f22a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -21,9 +21,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.concurrent.ScheduledFuture; -/** - * Created by ashvayka on 15.10.18. - */ @Data public class SessionMetaData { @@ -47,11 +44,8 @@ public class SessionMetaData { this.scheduledFuture = scheduledFuture; } - public ScheduledFuture getScheduledFuture() { - return scheduledFuture; - } - public boolean hasScheduledFuture() { return null != this.scheduledFuture; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java index a353cf94e4..fd144e0110 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class ToRuleEngineMsgEncoder implements TbKafkaEncoder { @Override public byte[] encode(ToRuleEngineMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java index 2e1d292a63..13a99686ad 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class ToTransportMsgResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java index 2de10a70fd..4a907c836a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiRequestEncoder implements TbKafkaEncoder { @Override public byte[] encode(TransportApiRequestMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java index cfb7168e66..563d1078c9 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index cd0efe2210..535baf921d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; import java.util.UUID; -/** - * @author Andrew Shvayka - */ @Data public abstract class DeviceAwareSessionContext implements SessionContext { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java index ee0786aeb1..df28bb3390 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java @@ -31,4 +31,5 @@ public interface SessionContext { void onDeviceProfileUpdate(TransportProtos.SessionInfoProto sessionInfo, DeviceProfile deviceProfile); void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional deviceProfileOpt); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java index ecdfc479cb..4d5451d4ca 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java @@ -27,8 +27,7 @@ import java.util.regex.Pattern; public class JsonUtils { - private static final Pattern BASE64_PATTERN = - Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); + private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); public static JsonObject getJsonObject(List tsKv) { JsonObject json = new JsonObject(); @@ -68,12 +67,12 @@ public class JsonUtils { } return JsonParser.parseString((String) value); } - } else if (value instanceof Boolean) { - return new JsonPrimitive((Boolean) value); - } else if (value instanceof Double) { - return new JsonPrimitive((Double) value); - } else if (value instanceof Float) { - return new JsonPrimitive((Float) value); + } else if (value instanceof Boolean booleanValue) { + return new JsonPrimitive(booleanValue); + } else if (value instanceof Double doubleValue) { + return new JsonPrimitive(doubleValue); + } else if (value instanceof Float floatValue) { + return new JsonPrimitive(floatValue); } else { throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName()); } @@ -91,4 +90,5 @@ public class JsonUtils { public static boolean isBase64(String value) { return value.length() % 4 == 0 && BASE64_PATTERN.matcher(value).matches(); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 30598925d5..0158e23d93 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -31,10 +31,6 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Base64; - -/** - * @author Valerii Sosliuk - */ @Slf4j public class SslUtil { @@ -51,7 +47,7 @@ public class SslUtil { String begin = "-----BEGIN CERTIFICATE-----"; String end = "-----END CERTIFICATE-----"; StringBuilder stringBuilder = new StringBuilder(); - for (Certificate cert: chain) { + for (Certificate cert : chain) { stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64.getEncoder().encodeToString(cert.getEncoded()))).append(end).append("\n"); } return stringBuilder.toString(); @@ -85,4 +81,5 @@ public class SslUtil { RDN cn = x500name.getRDNs(BCStyle.CN)[0]; return IETFUtils.valueToString(cn.getFirst().getValue()); } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java new file mode 100644 index 0000000000..6e16b2dc83 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java @@ -0,0 +1,182 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class SslCredentialsConfigTest { + + @Mock + private SslCredentials mockCredentials; + + private SslCredentialsConfig config; + + @BeforeEach + public void setup() { + config = new SslCredentialsConfig("Test SSL Config", false); + } + + @Test + public void givenConfig_whenCreated_thenShouldHaveCorrectName() { + assertThat(config.getName()).isEqualTo("Test SSL Config"); + assertThat(config.isTrustsOnly()).isFalse(); + } + + @Test + public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue(); + } + + @Test + public void givenCallback_whenRegistered_thenShouldBeStoredInList() { + AtomicInteger callCount = new AtomicInteger(0); + + config.registerReloadCallback(callCount::incrementAndGet); + config.setCredentials(mockCredentials); + + try { + doNothing().when(mockCredentials).reload(false); + } catch (Exception e) { + throw new RuntimeException(e); + } + + config.onCertificateFileChanged(); + + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + AtomicInteger callback3Count = new AtomicInteger(0); + + config.registerReloadCallback(callback1Count::incrementAndGet); + config.registerReloadCallback(callback2Count::incrementAndGet); + config.registerReloadCallback(callback3Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + assertThat(callback3Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + + config.registerReloadCallback(() -> { + callback1Count.incrementAndGet(); + throw new RuntimeException("Simulated callback failure"); + }); + config.registerReloadCallback(callback2Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + } + + @Test + public void givenCredentialsReloadFails_whenCertificateChanged_thenCallbacksShouldNotBeCalled() throws Exception { + AtomicInteger callbackCount = new AtomicInteger(0); + + config.registerReloadCallback(callbackCount::incrementAndGet); + config.setCredentials(mockCredentials); + + doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callbackCount.get()).isEqualTo(0); + } + + @Test + public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception { + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + verify(mockCredentials).reload(false); + } + + @Test + public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + trustsOnlyConfig.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(true); + + trustsOnlyConfig.onCertificateFileChanged(); + + verify(mockCredentials).reload(true); + } + + @Test + public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception { + AtomicInteger totalCallbacks = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(10); + + for (int i = 0; i < 10; i++) { + new Thread(() -> { + try { + startLatch.await(); + config.registerReloadCallback(totalCallbacks::incrementAndGet); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(totalCallbacks.get()).isEqualTo(10); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java new file mode 100644 index 0000000000..b03e1f2edc --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java @@ -0,0 +1,277 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SslCredentialsWebServerCustomizerTest { + + @Mock + private ServerProperties mockServerProperties; + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private KeyStore mockKeyStore; + + private SslCredentialsWebServerCustomizer customizer; + + @BeforeEach + public void setup() throws Exception { + customizer = new SslCredentialsWebServerCustomizer(mockServerProperties); + ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore); + when(mockCredentials.getKeyPassword()).thenReturn("password"); + when(mockCredentials.getKeyAlias()).thenReturn("server"); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes()); + when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert}); + } + + @Test + public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + + verify(mockCredentialsConfig, times(1)).getCredentials(); + } + + @Test + public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle = sslBundles.getBundle("default"); + + assertThat(bundle).isNotNull(); + } + + @Test + public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() { + SslBundles sslBundles = customizer.sslBundles(); + + List bundleNames = sslBundles.getBundleNames(); + + assertThat(bundleNames).containsExactly("default"); + } + + @Test + public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("default", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(1); + } + + @Test + public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("wrong-bundle", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(0); + } + + @Test + public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + AtomicInteger handler3CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + assertThat(handler3CallCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + reloadCallback.run(); + reloadCallback.run(); + + assertThat(handlerCallCount.get()).isEqualTo(3); + } + + @Test + public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> { + handler1CallCount.incrementAndGet(); + throw new RuntimeException("Handler 1 failed"); + }); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + reloadCallback.run(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(handlerCallCount.get()).isEqualTo(5); + } + + @Test + public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() { + when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials")); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + } + + @Test + public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle1 = sslBundles.getBundle("default"); + SslBundle bundle2 = sslBundles.getBundle("default"); + + assertThat(bundle1).isNotNull(); + assertThat(bundle2).isNotNull(); + } + + @Test + public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() { + SslCredentialsConfig config = customizer.httpServerSslCredentials(); + + assertThat(config).isNotNull(); + assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials"); + assertThat(config.isTrustsOnly()).isFalse(); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java new file mode 100644 index 0000000000..0c9f1fc5b1 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -0,0 +1,383 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CertificateReloadManagerTest { + + @TempDir + Path tempDir; + + private CertificateReloadManager certificateReloadManager; + private Path certFile; + + @BeforeEach + public void setup() throws IOException { + certificateReloadManager = new CertificateReloadManager(); + + certFile = tempDir.resolve("test-cert.pem"); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n"); + } + + @AfterEach + public void teardown() throws Exception { + if (certificateReloadManager != null) { + certificateReloadManager.destroy(); + } + } + + @Test + public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + CountDownLatch reloadLatch = new CountDownLatch(1); + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadCount.incrementAndGet(); + reloadLatch.countDown(); + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); + + assertThat(reloadTriggered).isTrue(); + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + Thread.sleep(100); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + Files.delete(certFile); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + Thread.sleep(100); + + // File deletion changes checksum from real hash to "", so reload is triggered + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { + certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); + + certificateReloadManager.destroy(); + + assertThat(certificateReloadManager).isNotNull(); + } + + @Test + public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception { + Path keyFile = tempDir.resolve("test-key.pem"); + Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); + + CountDownLatch certReloadLatch = new CountDownLatch(1); + CountDownLatch keyReloadLatch = new CountDownLatch(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); + certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); + + Thread.sleep(100); + Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + boolean keyReloaded = keyReloadLatch.await(2, TimeUnit.SECONDS); + + assertThat(keyReloaded).isTrue(); + assertThat(certReloadLatch.getCount()).isEqualTo(1); + } + + @Test + public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload1Count = new AtomicInteger(0); + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reload1Count.get()).isEqualTo(1); + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, () -> { + throw new RuntimeException("Simulated reload failure"); + }); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + Files.delete(certFile); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reloadCount.get()).isEqualTo(2); + } + + @Test + public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { + CountDownLatch reloadLatch = new CountDownLatch(1); + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadCount.incrementAndGet(); + reloadLatch.countDown(); + }); + + Thread.sleep(100); + + for (int i = 0; i < 5; i++) { + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); + } + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); + + assertThat(reloadTriggered).isTrue(); + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + // With atomic checkAndReload, exactly one reload should happen + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + String originalContent = Files.readString(certFile); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Thread.sleep(100); + + Files.writeString(certFile, originalContent); + Thread.sleep(100); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + Thread.sleep(200); + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + throw new RuntimeException("Persistent failure"); + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + + assertThat(reloadAttempts.get()).isEqualTo(10); + } + + @Test + public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Transient failure"); + } + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // First attempt fails + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(1); + + // Fix the callback and change the file to new content + shouldFail.set(0); + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Should reset failure counter and succeed because file content changed + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(2); + } + + @Test + public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Persistent failure"); + } + }); + + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Exhaust all retries + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + assertThat(reloadAttempts.get()).isEqualTo(10); + + // Fix callback and change file to new content + shouldFail.set(0); + Thread.sleep(100); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); + Thread.sleep(100); + + // Should detect new content, reset counter, and succeed + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(11); + } + +} diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 9ab06ca996..c7283b9c0c 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -170,6 +170,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # CoAP server parameters coap: diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index f869534088..d9ca77d8af 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -201,6 +201,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # Queue configuration parameters queue: diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index d22bd34505..12dbf24298 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -301,6 +301,15 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # Queue configuration properties queue: diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index ccbd3901ce..7be3879efe 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -234,6 +234,15 @@ transport: max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}" # Timeout to expire block IP addresses ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" # Queue configuration parameters queue: From 1d6594161b1a4f6069b5204b87376eb3663414a8 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:28:28 +0200 Subject: [PATCH 007/123] fix: force getLhServer() failure in LwM2m listener preservation test --- .../lwm2m/server/LwM2mServerCertificateReloadTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java index 15e4d07622..c8b7f0d060 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -159,6 +159,9 @@ public class LwM2mServerCertificateReloadTest { LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); + // Force getLhServer() to fail by returning null host + when(mockConfig.getHost()).thenReturn(null); + // Invoke the callback — new server creation will fail, old listeners should stay callbackCaptor.getValue().run(); From 255bb38dc2c0a3265376ce1a49dd1c5a0d905804 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:44:10 +0200 Subject: [PATCH 008/123] Fimprove SSL certificate reload robustness and config clarity --- application/src/main/resources/thingsboard.yml | 2 +- .../lwm2m/server/DefaultLwM2mTransportService.java | 8 +++++++- .../ssl/SslCredentialsWebServerCustomizer.java | 2 +- .../service/CertificateReloadManager.java | 14 ++++++++++---- .../coap/src/main/resources/tb-coap-transport.yml | 2 +- .../http/src/main/resources/tb-http-transport.yml | 2 +- .../src/main/resources/tb-lwm2m-transport.yml | 2 +- .../mqtt/src/main/resources/tb-mqtt-transport.yml | 2 +- 8 files changed, 23 insertions(+), 11 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 419e6d3dfd..3001ea4187 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1402,7 +1402,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters coap: diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 21fdc07277..21b90f8d0c 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -235,7 +235,13 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - newServer.start(); + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M server, rolling back", e); + newServer.destroy(); + throw e; + } try { LwM2mServerListener newListener = new LwM2mServerListener(handler); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index 147daefa7e..d213d9dc86 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -147,7 +147,7 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom @Override public void addBundleRegisterHandler(BiConsumer registerHandler) { - + log.debug("addBundleRegisterHandler is not supported for dynamic SSL bundles"); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 19fc545d0b..9940046b7d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.util.TbTransportComponent; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; @@ -50,7 +51,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis @Value("${transport.ssl.certificate.reload.enabled:true}") private boolean reloadEnabled; - @Value("${transport.ssl.certificate.reload.check_interval:60}") + @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") private long checkIntervalInSeconds; @Autowired @@ -258,9 +259,14 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis return ""; } MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] bytes = Files.readAllBytes(path); - byte[] hash = md.digest(bytes); - return Base64.getEncoder().encodeToString(hash); + byte[] buf = new byte[8192]; + try (InputStream is = Files.newInputStream(path)) { + int bytesRead; + while ((bytesRead = is.read(buf)) != -1) { + md.update(buf, 0, bytesRead); + } + } + return Base64.getEncoder().encodeToString(md.digest()); } catch (Exception e) { log.warn("Failed to calculate checksum for certificate file: {}", path, e); return ""; diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index c7283b9c0c..3c1ef94f09 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -178,7 +178,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters coap: diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index d9ca77d8af..1b221d1fd9 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -209,7 +209,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters queue: diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 12dbf24298..6140122062 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -309,7 +309,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration properties queue: diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 7be3879efe..ac02fa396b 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -242,7 +242,7 @@ transport: # Enable/disable automatic SSL certificates reload enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" # Check interval in seconds for certificates reload - check_interval: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL:60}" + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters queue: From c2a8f79edd42c421a86567c46443a7872c6bb362 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 15:54:02 +0200 Subject: [PATCH 009/123] Minor changing for CertificateReloadManager --- .../server/coapserver/DefaultCoapServerService.java | 2 +- .../server/common/data/ResourceUtils.java | 5 ++++- .../server/common/data/ResourceUtilsTest.java | 9 +++++---- .../bootstrap/LwM2MTransportBootstrapService.java | 2 +- .../lwm2m/server/DefaultLwM2mTransportService.java | 4 ++-- .../ssl/SslCredentialsWebServerCustomizer.java | 3 ++- .../transport/service/CertificateReloadManager.java | 12 +++++++++--- 7 files changed, 24 insertions(+), 13 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 7882125906..b073e34202 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -214,12 +214,12 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint..."); + server.getEndpoints().remove(oldDtlsEndpoint); oldDtlsEndpoint.stop(); if (oldDtlsConnector != null) { oldDtlsConnector.destroy(); } oldDtlsEndpoint.destroy(); - server.getEndpoints().remove(oldDtlsEndpoint); log.info("Old DTLS endpoint stopped and destroyed."); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java index 626d0cd372..13c75feed0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java @@ -111,7 +111,10 @@ public class ResourceUtils { return resourceFile.getAbsolutePath(); } else { URL url = classLoader.getResource(filePath); - return url != null ? url.toURI().toString() : null; + if (url == null) { + throw new RuntimeException("Unable to find resource: " + filePath); + } + return url.toURI().toString(); } } catch (Exception e) { if (e instanceof NullPointerException) { diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java index 8c91762068..8fb6214dac 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java @@ -18,14 +18,15 @@ package org.thingsboard.server.common.data; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class ResourceUtilsTest { @Test - public void givenNonExistentResource_whenGetUri_thenReturnsNull() { - String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt"); - - assertThat(result).isNull(); + public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() { + assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to find resource"); } @Test diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 78f292d69c..6de8acde90 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -64,7 +64,7 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto private final LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; private final TransportService transportService; private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; - private LeshanBootstrapServer server; + private volatile LeshanBootstrapServer server; @Override public void afterSingletonsInstantiated() { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 21b90f8d0c..91b3fb072f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -84,8 +84,8 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar private final TbLwM2MAuthorizer authorizer; private final LwM2mVersionedModelProvider modelProvider; - private LeshanServer server; - private LwM2mServerListener serverListener; + private volatile LeshanServer server; + private volatile LwM2mServerListener serverListener; @Override public void afterSingletonsInstantiated() { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index d213d9dc86..2a291b3888 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -22,6 +22,7 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.ssl.NoSuchSslBundleException; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.ssl.SslStoreBundle; @@ -125,7 +126,7 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom @Override public SslBundle getBundle(String name) { if (!DEFAULT_BUNDLE_NAME.equals(name)) { - throw new IllegalArgumentException("Unknown SSL bundle: " + name); + throw new NoSuchSslBundleException(name, "Unknown SSL bundle: " + name); } return createSslBundle(); } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 9940046b7d..a514b110cb 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -196,14 +196,20 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis for (Path path : paths) { String checksum = calculateChecksum(path); currentChecksums.put(path, checksum); - combined.append(checksum); + if (!combined.isEmpty()) { + combined.append("|"); + } + combined.append(path).append("=").append(checksum); } String combinedChecksum = combined.toString(); // Build old combined checksum for comparison StringBuilder oldCombined = new StringBuilder(); for (Path path : paths) { - oldCombined.append(lastChecksumMap.getOrDefault(path, "")); + if (!oldCombined.isEmpty()) { + oldCombined.append("|"); + } + oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); } String oldCombinedChecksum = oldCombined.toString(); @@ -216,7 +222,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { - // File content changed since last failure — reset and retry + // File content has changed since the last failure - reset and retry consecutiveFailures = 0; failedCombinedChecksum = null; } From edcf3a9d2295d9a8d425fc8750de4a7bd3f8f3e6 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:01:34 +0200 Subject: [PATCH 010/123] Fix SSL certificate reload: port conflict, redundant reload(), and watcher polling --- .../coapserver/DefaultCoapServerService.java | 25 +++++++++++++++---- .../CoapDtlsCertificateReloadTest.java | 9 ++++--- .../config/ssl/AbstractSslCredentials.java | 3 +-- .../service/CertificateReloadManager.java | 4 +++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index b073e34202..34e7e2cfde 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -195,14 +195,31 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + // Stop old endpoint first to release the port before starting the new one + if (oldDtlsEndpoint != null) { + log.info("Stopping old DTLS endpoint to release the port..."); + server.getEndpoints().remove(oldDtlsEndpoint); + oldDtlsEndpoint.stop(); + } + server.addEndpoint(newEndpoint); try { newEndpoint.start(); } catch (IOException e) { - log.error("Failed to start new DTLS endpoint, cleaning up", e); + log.error("Failed to start new DTLS endpoint, restoring old endpoint", e); server.getEndpoints().remove(newEndpoint); newEndpoint.destroy(); newConnector.destroy(); + // Attempt to restore the old endpoint + if (oldDtlsEndpoint != null) { + try { + server.addEndpoint(oldDtlsEndpoint); + oldDtlsEndpoint.start(); + log.info("Old DTLS endpoint restored successfully."); + } catch (IOException restoreEx) { + log.error("Failed to restore old DTLS endpoint", restoreEx); + } + } throw e; } log.info("New DTLS endpoint started successfully."); @@ -212,15 +229,13 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsCoapEndpoint = newEndpoint; tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + // Destroy old resources after successful swap if (oldDtlsEndpoint != null) { - log.info("Stopping old DTLS endpoint..."); - server.getEndpoints().remove(oldDtlsEndpoint); - oldDtlsEndpoint.stop(); if (oldDtlsConnector != null) { oldDtlsConnector.destroy(); } oldDtlsEndpoint.destroy(); - log.info("Old DTLS endpoint stopped and destroyed."); + log.info("Old DTLS endpoint destroyed."); } } diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java index 642f2e0be9..a7413bdadd 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -189,7 +189,7 @@ public class CoapDtlsCertificateReloadTest { } @Test - public void givenReloadCallback_whenStartFails_thenNewResourcesCleaned() throws Exception { + public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception { // GIVEN when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); @@ -210,6 +210,7 @@ public class CoapDtlsCertificateReloadTest { doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); // WHEN - the callback catches the IOException internally @@ -224,12 +225,12 @@ public class CoapDtlsCertificateReloadTest { verify(mockNewEndpoint).destroy(); verify(mockNewConnector).destroy(); assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old endpoint was stopped to release port, then restored after new one failed + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).start(); // Old fields preserved assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); - // Old endpoint not touched - verify(mockDtlsEndpoint, never()).stop(); - verify(mockDtlsEndpoint, never()).destroy(); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java index 9ffa4f7aac..55e6f63e5d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java @@ -60,8 +60,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { @Override public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException { - SslState newState = buildState(trustsOnly); - state.set(newState); + init(trustsOnly); } private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index a514b110cb..0dd1fae724 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -228,6 +228,10 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + } return; } From b431e4c0e605d288d954e8237973506b8fc48a13 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:11:50 +0200 Subject: [PATCH 011/123] Fix LwM2M server recreation: stop old server before starting new to avoid port conflict --- .../LwM2MTransportBootstrapService.java | 26 ++++++-- .../server/DefaultLwM2mTransportService.java | 63 ++++++++++-------- .../LwM2mBootstrapCertificateReloadTest.java | 19 ++++-- .../service/CertificateReloadManagerTest.java | 64 +++++++++---------- 4 files changed, 101 insertions(+), 71 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 6de8acde90..a9a4e364b5 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -184,21 +184,33 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto log.info("Creating new LwM2M Bootstrap server with updated certificates..."); LeshanBootstrapServer newServer = getLhBootstrapServer(); + + // Stop the old server first to release the ports before starting the new one + if (oldServer != null) { + log.info("Stopping old LwM2M Bootstrap server to release ports..."); + oldServer.destroy(); + } + try { newServer.start(); } catch (Exception e) { - log.error("Failed to start new LwM2M Bootstrap server, rolling back", e); + log.error("Failed to start new LwM2M Bootstrap server", e); newServer.destroy(); + // Attempt to restore the old server + if (oldServer != null) { + try { + LeshanBootstrapServer restoredServer = getLhBootstrapServer(); + restoredServer.start(); + this.server = restoredServer; + log.info("Restored LwM2M Bootstrap server with previous configuration."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M Bootstrap server", restoreEx); + } + } throw e; } this.server = newServer; log.info("New LwM2M Bootstrap server started successfully."); - - if (oldServer != null) { - log.info("Stopping old LwM2M Bootstrap server..."); - oldServer.destroy(); - log.info("Old LwM2M Bootstrap server stopped."); - } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index 91b3fb072f..b0d25729b3 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -235,33 +235,10 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - try { - newServer.start(); - } catch (Exception e) { - log.error("Failed to start new LwM2M server, rolling back", e); - newServer.destroy(); - throw e; - } - - try { - LwM2mServerListener newListener = new LwM2mServerListener(handler); - newServer.getRegistrationService().addListener(newListener.registrationListener); - newServer.getPresenceService().addListener(newListener.presenceListener); - newServer.getObservationService().addListener(newListener.observationListener); - newServer.getSendService().addListener(newListener.sendListener); - - this.server = newServer; - this.context.setServer(newServer); - this.serverListener = newListener; - } catch (Exception e) { - log.error("Failed to register listeners on new LwM2M server, rolling back", e); - newServer.destroy(); - throw e; - } - log.info("New LwM2M server started successfully."); + // Stop old server first to release the ports before starting the new one if (oldServer != null) { - log.info("Stopping old LwM2M server..."); + log.info("Stopping old LwM2M server to release ports..."); if (oldListener != null) { oldServer.getRegistrationService().removeListener(oldListener.registrationListener); oldServer.getPresenceService().removeListener(oldListener.presenceListener); @@ -269,8 +246,42 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar oldServer.getSendService().removeListener(oldListener.sendListener); } oldServer.destroy(); - log.info("Old LwM2M server stopped."); } + + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M server", e); + newServer.destroy(); + // Attempt to restore the old server + try { + LeshanServer restoredServer = getLhServer(); + restoredServer.start(); + LwM2mServerListener restoredListener = new LwM2mServerListener(handler); + restoredServer.getRegistrationService().addListener(restoredListener.registrationListener); + restoredServer.getPresenceService().addListener(restoredListener.presenceListener); + restoredServer.getObservationService().addListener(restoredListener.observationListener); + restoredServer.getSendService().addListener(restoredListener.sendListener); + this.server = restoredServer; + this.context.setServer(restoredServer); + this.serverListener = restoredListener; + log.info("Restored LwM2M server with previous configuration."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M server", restoreEx); + } + throw e; + } + + LwM2mServerListener newListener = new LwM2mServerListener(handler); + newServer.getRegistrationService().addListener(newListener.registrationListener); + newServer.getPresenceService().addListener(newListener.presenceListener); + newServer.getObservationService().addListener(newListener.observationListener); + newServer.getSendService().addListener(newListener.sendListener); + + this.server = newServer; + this.context.setServer(newServer); + this.serverListener = newListener; + log.info("New LwM2M server started successfully."); } @Override diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java index 51d37ca739..84bc44b03b 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -118,8 +118,8 @@ public class LwM2mBootstrapCertificateReloadTest { Runnable reloadCallback = callbackCaptor.getValue(); - // getLhBootstrapServer() will fail due to null host. - // With create-then-swap, the old server should NOT be destroyed. + // getLhBootstrapServer() will fail due to null host before old server is stopped. + // The old server should NOT be destroyed since the new server was never created. reloadCallback.run(); verify(mockBootstrapServer, never()).destroy(); @@ -164,15 +164,18 @@ public class LwM2mBootstrapCertificateReloadTest { } @Test - public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndOldPreserved() { + public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndRestorationAttempted() { // GIVEN ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); + LeshanBootstrapServer mockRestoredServer = mock(LeshanBootstrapServer.class); + LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); - doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); + // First call returns the failing server, second call returns the restoration server + doReturn(mockNewServer).doReturn(mockRestoredServer).when(spyService).getLhBootstrapServer(); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); spyService.afterSingletonsInstantiated(); @@ -184,9 +187,13 @@ public class LwM2mBootstrapCertificateReloadTest { reloadCallback.run(); // THEN + // Old server is destroyed to release ports + verify(mockBootstrapServer).destroy(); + // New server fails to start and is destroyed verify(mockNewServer).destroy(); - assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); - verify(mockBootstrapServer, never()).destroy(); + // Restoration server is started and becomes the active server + verify(mockRestoredServer).start(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockRestoredServer); } } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index 0c9f1fc5b1..ba3aa66c70 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -63,7 +63,7 @@ public class CertificateReloadManagerTest { reloadLatch.countDown(); }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -80,9 +80,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -94,7 +94,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -107,13 +107,13 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.delete(certFile); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // File deletion changes checksum from real hash to "", so reload is triggered assertThat(reloadCount.get()).isEqualTo(1); @@ -139,9 +139,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -162,10 +162,10 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -186,10 +186,10 @@ public class CertificateReloadManagerTest { }); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -203,15 +203,15 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.delete(certFile); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -229,12 +229,12 @@ public class CertificateReloadManagerTest { reloadLatch.countDown(); }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); for (int i = 0; i < 5; i++) { Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); } - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -252,9 +252,9 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); for (int i = 0; i < 5; i++) { new Thread(() -> { @@ -284,10 +284,10 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, originalContent); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -304,9 +304,9 @@ public class CertificateReloadManagerTest { throw new RuntimeException("Persistent failure"); }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops for (int i = 0; i < 15; i++) { @@ -328,9 +328,9 @@ public class CertificateReloadManagerTest { } }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // First attempt fails ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -338,9 +338,9 @@ public class CertificateReloadManagerTest { // Fix the callback and change the file to new content shouldFail.set(0); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Should reset failure counter and succeed because file content changed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); @@ -359,9 +359,9 @@ public class CertificateReloadManagerTest { } }); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Exhaust all retries for (int i = 0; i < 15; i++) { @@ -371,9 +371,9 @@ public class CertificateReloadManagerTest { // Fix callback and change file to new content shouldFail.set(0); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); - Thread.sleep(100); + TimeUnit.MILLISECONDS.sleep(100); // Should detect new content, reset counter, and succeed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); From 22d9506206b8b588fe342f3a38dcd81faa590c39 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:28:18 +0200 Subject: [PATCH 012/123] Improve SSL certificate reload: rollback safety, defaults, and test encapsulation --- .../src/main/resources/thingsboard.yml | 2 +- .../coapserver/DefaultCoapServerService.java | 10 +- .../CoapDtlsCertificateReloadTest.java | 120 ++++++++++-------- .../LwM2MTransportBootstrapService.java | 17 ++- .../server/DefaultLwM2mTransportService.java | 38 +++--- .../LwM2mBootstrapCertificateReloadTest.java | 21 ++- .../LwM2mServerCertificateReloadTest.java | 3 +- .../service/CertificateReloadManager.java | 4 +- .../src/main/resources/tb-coap-transport.yml | 2 +- .../src/main/resources/tb-http-transport.yml | 2 +- .../src/main/resources/tb-lwm2m-transport.yml | 2 +- .../src/main/resources/tb-mqtt-transport.yml | 2 +- 12 files changed, 120 insertions(+), 103 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 3001ea4187..e4bd13cdb3 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1400,7 +1400,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 34e7e2cfde..087f8137c7 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -154,14 +154,14 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial return networkConfig; } - DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { + private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); return dtlsConnectorConfig; } - CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { + private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); dtlsCoapEndpointBuilder.setConfiguration(networkConfig); dtlsCoapEndpointBuilder.setConnector(connector); @@ -179,7 +179,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); } - DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { + private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { return new DTLSConnector(config); } @@ -195,7 +195,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); - // Stop old endpoint first to release the port before starting the new one + // Stop the old endpoint first to release the port before starting the new one if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint to release the port..."); server.getEndpoints().remove(oldDtlsEndpoint); @@ -229,7 +229,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial dtlsCoapEndpoint = newEndpoint; tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); - // Destroy old resources after successful swap + // Destroy old resources after a successful swap if (oldDtlsEndpoint != null) { if (oldDtlsConnector != null) { oldDtlsConnector.destroy(); diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java index a7413bdadd..f22aaf5c7e 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -18,7 +18,6 @@ package org.thingsboard.server.coapserver; import org.eclipse.californium.core.CoapServer; import org.eclipse.californium.core.network.CoapEndpoint; import org.eclipse.californium.core.network.Endpoint; -import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; import org.junit.jupiter.api.BeforeEach; @@ -26,22 +25,23 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; -import org.mockito.Mockito; +import org.mockito.MockedConstruction; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.springframework.test.util.ReflectionTestUtils; import java.io.IOException; +import java.net.InetSocketAddress; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -155,37 +155,42 @@ public class CoapDtlsCertificateReloadTest { // GIVEN when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); - CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); - DTLSConnector mockNewConnector = mock(DTLSConnector.class); DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); - DefaultCoapServerService spyService = Mockito.spy(coapServerService); - ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); - ReflectionTestUtils.setField(spyService, "server", mockCoapServer); - ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); - ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); - - doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); - doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); - doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); List endpointsList = new CopyOnWriteArrayList<>(); endpointsList.add(mockDtlsEndpoint); when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); - // WHEN - ReflectionTestUtils.invokeMethod(spyService, "recreateDtlsEndpoint"); - - // THEN - assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); - verify(mockDtlsEndpoint).stop(); - verify(mockDtlsEndpoint).destroy(); - verify(mockDtlsConnector).destroy(); - verify(mockCoapServer).addEndpoint(mockNewEndpoint); - verify(mockNewEndpoint).start(); - assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint"); + + // THEN + assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).destroy(); + verify(mockDtlsConnector).destroy(); + verify(mockCoapServer).addEndpoint(mockNewEndpoint); + verify(mockNewEndpoint).start(); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + } } @Test @@ -193,44 +198,49 @@ public class CoapDtlsCertificateReloadTest { // GIVEN when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); - CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); - DTLSConnector mockNewConnector = mock(DTLSConnector.class); DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); - doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); - - DefaultCoapServerService spyService = Mockito.spy(coapServerService); - ReflectionTestUtils.setField(spyService, "coapServerContext", mockCoapServerContext); - ReflectionTestUtils.setField(spyService, "server", mockCoapServer); - ReflectionTestUtils.setField(spyService, "dtlsCoapEndpoint", mockDtlsEndpoint); - ReflectionTestUtils.setField(spyService, "dtlsConnector", mockDtlsConnector); - - doReturn(mockDtlsConfig).when(spyService).buildDtlsConnectorConfig(any(Configuration.class)); - doReturn(mockNewConnector).when(spyService).createDtlsConnector(any(DtlsConnectorConfig.class)); - doReturn(mockNewEndpoint).when(spyService).buildDtlsEndpoint(any(Configuration.class), any(DTLSConnector.class)); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); List endpointsList = new CopyOnWriteArrayList<>(); endpointsList.add(mockDtlsEndpoint); when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); - // WHEN - the callback catches the IOException internally - spyService.afterSingletonsInstantiated(); - - ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); - verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); - Runnable reloadCallback = callbackCaptor.getValue(); - reloadCallback.run(); + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); - // THEN - new resources cleaned up - verify(mockNewEndpoint).destroy(); - verify(mockNewConnector).destroy(); - assertThat(endpointsList).doesNotContain(mockNewEndpoint); - // Old endpoint was stopped to release port, then restored after new one failed - verify(mockDtlsEndpoint).stop(); - verify(mockDtlsEndpoint).start(); - // Old fields preserved - assertThat(ReflectionTestUtils.getField(spyService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); - assertThat(ReflectionTestUtils.getField(spyService, "dtlsConnector")).isSameAs(mockDtlsConnector); + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + coapServerService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + reloadCallback.run(); + + // THEN - new resources cleaned up + DTLSConnector constructedConnector = dtlsMock.constructed().get(0); + verify(mockNewEndpoint).destroy(); + verify(constructedConnector).destroy(); + assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old endpoint was stopped to release port, then restored after new one failed + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).start(); + // Old fields preserved + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector); + } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index a9a4e364b5..9b370d0b71 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -185,10 +185,10 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto log.info("Creating new LwM2M Bootstrap server with updated certificates..."); LeshanBootstrapServer newServer = getLhBootstrapServer(); - // Stop the old server first to release the ports before starting the new one + // Stop (not destroy) the old server to release ports but keep it restartable for rollback if (oldServer != null) { log.info("Stopping old LwM2M Bootstrap server to release ports..."); - oldServer.destroy(); + oldServer.stop(); } try { @@ -196,13 +196,11 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto } catch (Exception e) { log.error("Failed to start new LwM2M Bootstrap server", e); newServer.destroy(); - // Attempt to restore the old server + // Attempt to restart the old server (only stopped, not destroyed) if (oldServer != null) { try { - LeshanBootstrapServer restoredServer = getLhBootstrapServer(); - restoredServer.start(); - this.server = restoredServer; - log.info("Restored LwM2M Bootstrap server with previous configuration."); + oldServer.start(); + log.info("Restored old LwM2M Bootstrap server successfully."); } catch (Exception restoreEx) { log.error("Failed to restore old LwM2M Bootstrap server", restoreEx); } @@ -211,6 +209,11 @@ public class LwM2MTransportBootstrapService implements SmartInitializingSingleto } this.server = newServer; log.info("New LwM2M Bootstrap server started successfully."); + + // Destroy the old server only after a successful swap + if (oldServer != null) { + oldServer.destroy(); + } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index b0d25729b3..5815a47555 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -236,7 +236,7 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar log.info("Creating new LwM2M server with updated certificates..."); LeshanServer newServer = getLhServer(); - // Stop old server first to release the ports before starting the new one + // Stop (not destroy) old server to release ports but keep it restartable for rollback if (oldServer != null) { log.info("Stopping old LwM2M server to release ports..."); if (oldListener != null) { @@ -245,7 +245,7 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar oldServer.getObservationService().removeListener(oldListener.observationListener); oldServer.getSendService().removeListener(oldListener.sendListener); } - oldServer.destroy(); + oldServer.stop(); } try { @@ -253,21 +253,20 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar } catch (Exception e) { log.error("Failed to start new LwM2M server", e); newServer.destroy(); - // Attempt to restore the old server - try { - LeshanServer restoredServer = getLhServer(); - restoredServer.start(); - LwM2mServerListener restoredListener = new LwM2mServerListener(handler); - restoredServer.getRegistrationService().addListener(restoredListener.registrationListener); - restoredServer.getPresenceService().addListener(restoredListener.presenceListener); - restoredServer.getObservationService().addListener(restoredListener.observationListener); - restoredServer.getSendService().addListener(restoredListener.sendListener); - this.server = restoredServer; - this.context.setServer(restoredServer); - this.serverListener = restoredListener; - log.info("Restored LwM2M server with previous configuration."); - } catch (Exception restoreEx) { - log.error("Failed to restore old LwM2M server", restoreEx); + // Attempt to restart the old server (only stopped, not destroyed) + if (oldServer != null) { + try { + oldServer.start(); + if (oldListener != null) { + oldServer.getRegistrationService().addListener(oldListener.registrationListener); + oldServer.getPresenceService().addListener(oldListener.presenceListener); + oldServer.getObservationService().addListener(oldListener.observationListener); + oldServer.getSendService().addListener(oldListener.sendListener); + } + log.info("Restored old LwM2M server successfully."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M server", restoreEx); + } } throw e; } @@ -282,6 +281,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService, Smar this.context.setServer(newServer); this.serverListener = newListener; log.info("New LwM2M server started successfully."); + + // Destroy old server only after successful swap + if (oldServer != null) { + oldServer.destroy(); + } } @Override diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java index 84bc44b03b..bbcbc921f6 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -122,6 +122,7 @@ public class LwM2mBootstrapCertificateReloadTest { // The old server should NOT be destroyed since the new server was never created. reloadCallback.run(); + verify(mockBootstrapServer, never()).stop(); verify(mockBootstrapServer, never()).destroy(); assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); } @@ -164,18 +165,15 @@ public class LwM2mBootstrapCertificateReloadTest { } @Test - public void givenReloadCallback_whenNewServerStartFails_thenNewServerDestroyedAndRestorationAttempted() { + public void givenReloadCallback_whenNewServerStartFails_thenOldServerRestarted() { // GIVEN ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); - LeshanBootstrapServer mockRestoredServer = mock(LeshanBootstrapServer.class); - LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); - // First call returns the failing server, second call returns the restoration server - doReturn(mockNewServer).doReturn(mockRestoredServer).when(spyService).getLhBootstrapServer(); + doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); spyService.afterSingletonsInstantiated(); @@ -187,13 +185,14 @@ public class LwM2mBootstrapCertificateReloadTest { reloadCallback.run(); // THEN - // Old server is destroyed to release ports - verify(mockBootstrapServer).destroy(); - // New server fails to start and is destroyed + // Old server is stopped (not destroyed) to release ports + verify(mockBootstrapServer).stop(); + verify(mockBootstrapServer, never()).destroy(); + // The new server fails to start and is destroyed verify(mockNewServer).destroy(); - // Restoration server is started and becomes the active server - verify(mockRestoredServer).start(); - assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockRestoredServer); + // Old server is restarted (not rebuilt from potentially stale credentials) + verify(mockBootstrapServer).start(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); } } diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java index c8b7f0d060..93b74447fd 100644 --- a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -139,9 +139,10 @@ public class LwM2mServerCertificateReloadTest { // Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw) when(mockConfig.getHost()).thenReturn(null); - // With create-then-swap, the old server should NOT be destroyed if the new one fails. + // With create-then-swap, the old server should NOT be stopped/destroyed if the new one fails to build. reloadCallback.run(); + verify(mockLeshanServer, never()).stop(); verify(mockLeshanServer, never()).destroy(); // Old server should still be the active one assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index 0dd1fae724..f243c64c50 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -48,7 +48,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis private static final int MAX_CONSECUTIVE_FAILURES = 10; - @Value("${transport.ssl.certificate.reload.enabled:true}") + @Value("${transport.ssl.certificate.reload.enabled:false}") private boolean reloadEnabled; @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") @@ -103,7 +103,7 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis List filePaths = credentials.getCertificateFilePaths(); if (filePaths == null || filePaths.isEmpty()) { - log.debug("No certificate files to watch for: {} ({})", config.getName(), beanName); + log.debug("No file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName); continue; } diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index 3c1ef94f09..f9827d488f 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -176,7 +176,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 1b221d1fd9..7efa428a90 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -207,7 +207,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 6140122062..543b11859b 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -307,7 +307,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index ac02fa396b..60d3da8f8e 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -240,7 +240,7 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" # Check interval in seconds for certificates reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" From 635920534d3efe5d25816eb987d604417b5b6d13 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Wed, 25 Mar 2026 16:37:40 +0200 Subject: [PATCH 013/123] Improve SSL reload clarity: document trade-offs, use Path API, deduplicate PEM path logic --- .../coapserver/DefaultCoapServerService.java | 5 +++- .../config/ssl/KeystoreSslCredentials.java | 8 +++---- .../config/ssl/PemSslCredentials.java | 24 ++++++++----------- .../service/CertificateReloadManager.java | 2 ++ 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 087f8137c7..03de8b7b91 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -154,6 +154,7 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial return networkConfig; } + // Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig. private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); @@ -195,7 +196,9 @@ public class DefaultCoapServerService implements CoapServerService, SmartInitial DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); - // Stop the old endpoint first to release the port before starting the new one + // Californium binds the DTLS port at connector construction time, so we must stop the old + // endpoint first to release the port. This creates a brief window where the port is unbound; + // if the new endpoint fails to start, we attempt to restore the old one (see rollback below). if (oldDtlsEndpoint != null) { log.info("Stopping old DTLS endpoint to release the port..."); server.getEndpoints().remove(oldDtlsEndpoint); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 7a2fb1a545..7cbc4403b7 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -20,9 +20,9 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; -import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -62,9 +62,9 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { @Override public List getCertificateFilePaths() { if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - File storeFileObj = new File(storeFile); - if (storeFileObj.exists()) { - return Collections.singletonList(storeFileObj.toPath().toAbsolutePath()); + Path resolved = Path.of(storeFile).toAbsolutePath(); + if (Files.exists(resolved)) { + return Collections.singletonList(resolved); } } return Collections.emptyList(); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index 72ad7af9c5..c6eb75698e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -30,10 +30,10 @@ import org.bouncycastle.openssl.jcajce.JcePEMDecryptorProviderBuilder; import org.thingsboard.server.common.data.ResourceUtils; import org.thingsboard.server.common.data.StringUtils; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; @@ -145,22 +145,18 @@ public class PemSslCredentials extends AbstractSslCredentials { @Override public List getCertificateFilePaths() { List paths = new ArrayList<>(); + addIfFileSystemPath(paths, certFile); + addIfFileSystemPath(paths, keyFile); + return paths; + } - if (!StringUtils.isEmpty(certFile) && !certFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - File certFileObj = new File(certFile); - if (certFileObj.exists()) { - paths.add(certFileObj.toPath().toAbsolutePath()); - } - } - - if (!StringUtils.isEmpty(keyFile) && !keyFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { - File keyFileObj = new File(keyFile); - if (keyFileObj.exists()) { - paths.add(keyFileObj.toPath().toAbsolutePath()); + private static void addIfFileSystemPath(List paths, String filePath) { + if (!StringUtils.isEmpty(filePath) && !filePath.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + Path resolved = Path.of(filePath).toAbsolutePath(); + if (Files.exists(resolved)) { + paths.add(resolved); } } - - return paths; } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java index f243c64c50..eec84a22f0 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -247,6 +247,8 @@ public class CertificateReloadManager implements SmartInitializingSingleton, Dis } catch (Exception e) { consecutiveFailures++; failedCombinedChecksum = combinedChecksum; + // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries + // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). log.error("Failed to reload certificate for {} (attempt {}/{}): {}", name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); } From 92390ae2f1d2daf9a330e24e11b827d1f5509bb0 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Wed, 25 Mar 2026 17:03:21 +0200 Subject: [PATCH 014/123] added NoXss for AlarmCreateOrUpdateActiveRequest.type --- .../common/data/alarm/AlarmCreateOrUpdateActiveRequest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java index c456323120..65e943c574 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java @@ -39,6 +39,7 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques private TenantId tenantId; @Schema(description = "JSON object with Customer Id", accessMode = Schema.AccessMode.READ_ONLY) private CustomerId customerId; + @NoXss @NotNull @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm") @Length(fieldName = "type") From 1317a446390f5b31e570a1383622709a07f0f677 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 26 Mar 2026 10:18:11 +0200 Subject: [PATCH 015/123] Add reload integration tests --- ...pDtlsCertificateReloadIntegrationTest.java | 349 ++++++++++++++++++ ...ttSslCertificateReloadIntegrationTest.java | 276 ++++++++++++++ 2 files changed, 625 insertions(+) create mode 100644 common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java create mode 100644 common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..11e278b4f5 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java @@ -0,0 +1,349 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.eclipse.californium.core.CoapClient; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.config.CoapConfig; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.elements.util.SslContextUtil; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConfig; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.eclipse.californium.scandium.dtls.CertificateType; +import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; +import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType; + +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY; + +public class CoapDtlsCertificateReloadIntegrationTest { + + private static final String TEST_RESOURCE_PATH = "test"; + private static final String TEST_PAYLOAD = "hello-dtls"; + + @TempDir + Path tempDir; + + private CoapServer coapServer; + + @AfterEach + public void teardown() { + if (coapServer != null) { + coapServer.destroy(); + } + } + + @Test + public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD); + } + + @Test + public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA); + assertThat(failedResponse).isNull(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + @Test + public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint1); + coapServer.start(); + + CoapResponse response1 = doDtlsRequest(dtlsPort, cert); + assertThat(response1).isNotNull(); + assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpoint1); + endpoint1.stop(); + CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint2); + endpoint2.start(); + endpoint1.destroy(); + + CoapResponse response2 = doDtlsRequest(dtlsPort, cert); + assertThat(response2).isNotNull(); + assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false); + config.setEnabled(true); + config.setType(SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new KeystoreSslCredentials()); + config.init(); + return config; + } + + private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) { + DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config); + dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); + dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY); + dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS); + dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE, + org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED); + + SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials( + credentials.getPrivateKey(), null, credentials.getCertificateChain()); + + dtlsBuilder.setCertificateIdentityProvider( + new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(), + Collections.singletonList(CertificateType.X_509))); + + dtlsBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustAllCertificates() + .build()); + + DTLSConnector connector = new DTLSConnector(dtlsBuilder.build()); + + CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder(); + endpointBuilder.setConfiguration(config); + endpointBuilder.setConnector(connector); + return endpointBuilder.build(); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private Configuration createServerConfig() { + Configuration config = new Configuration(); + config.set(CoapConfig.MAX_RETRANSMIT, 2); + config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + return config; + } + + private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) { + try { + Configuration clientConfig = new Configuration(); + clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1); + clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); + clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS); + clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false); + clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false); + + DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig); + clientDtls.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustedCert) + .build()); + + DTLSConnector clientConnector = new DTLSConnector(clientDtls.build()); + CoapEndpoint clientEndpoint = new CoapEndpoint.Builder() + .setConfiguration(clientConfig) + .setConnector(clientConnector) + .build(); + + CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH); + client.setEndpoint(clientEndpoint); + client.setTimeout((long) 5000); + + try { + clientEndpoint.start(); + return client.get(); + } finally { + client.shutdown(); + clientEndpoint.destroy(); + } + } catch (Exception e) { + return null; + } + } + + private int findAvailablePort() throws Exception { + try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) { + return socket.getLocalPort(); + } + } + + private static class TestResource extends CoapResource { + TestResource() { + super(TEST_RESOURCE_PATH); + } + + @Override + public void handleGET(CoapExchange exchange) { + exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD); + } + + } + +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..84ef4f2979 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java @@ -0,0 +1,276 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class MqttSslCertificateReloadIntegrationTest { + + @TempDir + Path tempDir; + + @Mock + private TransportService transportService; + + @Test + public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctxA = getProviderSslContext(provider); + X509Certificate servedA; + try (SSLServerSocket ss = createServerSocket(ctxA)) { + servedA = doHandshakeAndGetServerCert(ss); + } + assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal()); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctxB = getProviderSslContext(provider); + assertThat(ctxB).isNotSameAs(ctxA); + X509Certificate servedB; + try (SSLServerSocket ss = createServerSocket(ctxB)) { + servedB = doHandshakeAndGetServerCert(ss); + } + assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal()); + assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + assertThat(ctx1).isNotNull(); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctx2 = getProviderSslContext(provider); + assertThat(ctx2).isNotSameAs(ctx1); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx2)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + KeyPair keyPairC = generateKeyPair(); + X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx2 = getProviderSslContext(provider); + + writeCertPem(certFile, certC); + writeKeyPem(keyFile, keyPairC); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx3 = getProviderSslContext(provider); + + assertThat(ctx1).isNotSameAs(ctx2); + assertThat(ctx2).isNotSameAs(ctx3); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx3)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal()); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false); + config.setEnabled(true); + config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials()); + config.init(); + return config; + } + + private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) { + MqttSslHandlerProvider provider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2"); + ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(provider, "transportService", transportService); + provider.afterSingletonsInstantiated(); + return provider; + } + + /** + * Triggers SSLContext creation through the provider's getSslHandler() path, + * then extracts the cached SSLContext for direct server socket use. + */ + private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) { + provider.getSslHandler(); + return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext"); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception { + return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress()); + } + + private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception { + Thread acceptor = new Thread(() -> { + try (var conn = serverSocket.accept()) { + conn.getInputStream().read(); + } catch (Exception ignored) {} + }); + acceptor.setDaemon(true); + acceptor.start(); + + SSLContext clientCtx = SSLContext.getInstance("TLSv1.2"); + clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null); + + try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory() + .createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { + client.setSoTimeout(5000); + client.startHandshake(); + + Certificate[] peerCerts = client.getSession().getPeerCertificates(); + assertThat(peerCerts).isNotEmpty(); + return (X509Certificate) peerCerts[0]; + } finally { + acceptor.join(5000); + } + } + + private static class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + } + +} From cfc3935860b2eed06d9ccb0444b41ecc56c87ac5 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Thu, 26 Mar 2026 12:07:46 +0200 Subject: [PATCH 016/123] Set TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED default to 'true' --- application/src/main/resources/thingsboard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index e4bd13cdb3..f297fadde0 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1400,8 +1400,8 @@ transport: # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) reload: # Enable/disable automatic SSL certificates reload - enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:false}" - # Check interval in seconds for certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check an interval in seconds for certificate reload check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters From cffd2e96cc79185a70a14a4ac0740f3a0411933a Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Thu, 26 Mar 2026 16:27:50 +0200 Subject: [PATCH 017/123] fixed WS limit handling for "Sessions per public user maximum number" --- .../controller/plugin/TbWebSocketHandler.java | 6 +- .../service/ws/DefaultWebSocketService.java | 6 +- .../plugin/TbWebSocketHandlerTest.java | 78 +++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 73315be73f..133584201c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -115,7 +115,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private final ConcurrentMap> tenantSessionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSessionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSessionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); private Cache pendingSessions; @@ -611,7 +611,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { limitAllowed = publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser(); if (limitAllowed) { @@ -655,7 +655,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } } if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.remove(sessionId); } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 4b1a81dd2d..09651a9264 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService { private final ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>(); private ExecutorService executor; @@ -340,7 +340,7 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.removeIf(subId -> subId.startsWith(sessionId)); } @@ -401,7 +401,7 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { publicUserSessions.add(subId); diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java index 053cb6808f..4dc637725f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java @@ -25,16 +25,28 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.ws.WebSocketSessionRef; import java.io.IOException; +import java.lang.reflect.Method; import java.util.Collection; import java.util.Deque; import java.util.List; import java.util.Random; +import java.util.UUID; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; @@ -184,4 +196,70 @@ class TbWebSocketHandlerTest { assertThat(msgs).map(Integer::parseInt).doesNotHaveDuplicates().hasSize(100); } + // Regression test for the bug where publicUserSessionsMap was keyed by UserId(NULL_UUID), + // making maxWsSessionsPerPublicUser a global limit shared across all tenants. + // The limit is now scoped per-tenant. + @Test + void checkLimits_publicUserSessions_limitIsPerTenantNotGlobal() throws Exception { + TbTenantProfileCache tenantProfileCache = mock(TbTenantProfileCache.class); + ReflectionTestUtils.setField(wsHandler, "tenantProfileCache", tenantProfileCache); + + int maxPublicSessions = 2; + + TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile1 = new TenantProfile(); + profile1.createDefaultTenantProfileData(); + profile1.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions); + willReturn(profile1).given(tenantProfileCache).get(tenant1); + + TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile2 = new TenantProfile(); + profile2.createDefaultTenantProfileData(); + profile2.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions); + willReturn(profile2).given(tenantProfileCache).get(tenant2); + + Method checkLimits = TbWebSocketHandler.class.getDeclaredMethod( + "checkLimits", WebSocketSession.class, WebSocketSessionRef.class); + checkLimits.setAccessible(true); + + // tenant1 fills up its limit + for (int i = 0; i < maxPublicSessions; i++) { + assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t1-" + i), mockPublicSessionRef(tenant1))).isTrue(); + } + + // tenant2 must get its own independent quota — this was the bug: with NULL_UUID as key + // all tenants shared one global counter, so tenant2 would be blocked here + for (int i = 0; i < maxPublicSessions; i++) { + assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t2-" + i), mockPublicSessionRef(tenant2))) + .as("tenant2 session %d should not be affected by tenant1's sessions", i + 1) + .isTrue(); + } + + // tenant1's (maxPublicSessions + 1)-th session must be rejected + NativeWebSocketSession overLimit = mockWsSession("t1-over"); + assertThat((boolean) checkLimits.invoke(wsHandler, overLimit, mockPublicSessionRef(tenant1))).isFalse(); + verify(overLimit).close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached")); + } + + private NativeWebSocketSession mockWsSession(String id) { + NativeWebSocketSession s = mock(NativeWebSocketSession.class); + willReturn(id).given(s).getId(); + return s; + } + + private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId) { + CustomerId customerId = new CustomerId(UUID.randomUUID()); + SecurityUser securityUser = mock(SecurityUser.class); + willReturn(tenantId).given(securityUser).getTenantId(); + willReturn(customerId).given(securityUser).getCustomerId(); + willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId(); + willReturn(true).given(securityUser).isCustomerUser(); + willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal(); + + WebSocketSessionRef ref = mock(WebSocketSessionRef.class); + willReturn(securityUser).given(ref).getSecurityCtx(); + willReturn(UUID.randomUUID().toString()).given(ref).getSessionId(); + return ref; + } + } From 25d3394e9f0700abad5f0250fb5c1704a86c3829 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Thu, 26 Mar 2026 11:10:39 +0100 Subject: [PATCH 018/123] Fix race condition in notification deduplication check The alreadyProcessed() method used separate get() and put() calls on the local cache, allowing concurrent threads in the notification executor pool to both read null and bypass deduplication, creating duplicate notifications. Replace with a single compute() call that atomically checks and updates the cache entry, preventing the race between concurrent trigger processing. Also fix: discard external cache timestamps that are more than 1 hour in the future (clock skew protection), and avoid reading back from the SOFT ref local cache when writing to external cache (GC could null it out). --- ...faultNotificationDeduplicationService.java | 56 +++--- ...tNotificationDeduplicationServiceTest.java | 160 ++++++++++++++++++ 2 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java index bf884958eb..279a2ddc7f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java @@ -32,6 +32,7 @@ import org.thingsboard.server.queue.util.PropertyUtils; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT; @@ -59,41 +60,48 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu } private boolean alreadyProcessed(NotificationRuleTrigger trigger, String deduplicationKey, boolean onlyLocalCache) { - Long lastProcessedTs = localCache.get(deduplicationKey); - if (lastProcessedTs == null && !onlyLocalCache) { - Cache externalCache = getExternalCache(); - if (externalCache != null) { - lastProcessedTs = externalCache.get(deduplicationKey, Long.class); - } else { - log.warn("Sent notifications cache is not set up"); + long deduplicationDuration = getDeduplicationDuration(trigger); + final long now = System.currentTimeMillis(); + boolean[] result = {false}; + + localCache.compute(deduplicationKey, (key, lastProcessedTs) -> { + if (lastProcessedTs == null && !onlyLocalCache) { + Cache externalCache = getExternalCache(); + if (externalCache != null) { + lastProcessedTs = externalCache.get(key, Long.class); + if (lastProcessedTs != null && lastProcessedTs > now + TimeUnit.HOURS.toMillis(1)) { + log.warn("Discarding dedup entry from external cache for key '{}': timestamp is {} ms in the future", + key, lastProcessedTs - now); + lastProcessedTs = null; + } + } else { + log.warn("Sent notifications cache is not set up"); + } } - } - boolean alreadyProcessed = false; - long deduplicationDuration = getDeduplicationDuration(trigger); - if (lastProcessedTs != null) { - long passed = System.currentTimeMillis() - lastProcessedTs; - log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", - trigger.getType(), deduplicationKey, deduplicationDuration, passed); - if (deduplicationDuration == 0 || passed <= deduplicationDuration) { - alreadyProcessed = true; + if (lastProcessedTs != null) { + long passed = now - lastProcessedTs; + log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", + trigger.getType(), key, deduplicationDuration, passed); + if (deduplicationDuration == 0 || passed <= deduplicationDuration) { + result[0] = true; + return lastProcessedTs; + } } - } - if (!alreadyProcessed) { - lastProcessedTs = System.currentTimeMillis(); - } - localCache.put(deduplicationKey, lastProcessedTs); + return now; + }); + if (!onlyLocalCache) { - if (!alreadyProcessed || deduplicationDuration == 0) { + if (!result[0] || deduplicationDuration == 0) { // if lastProcessedTs is changed or if deduplicating infinitely (so that cache value not removed by ttl) Cache externalCache = getExternalCache(); if (externalCache != null) { - externalCache.put(deduplicationKey, lastProcessedTs); + externalCache.put(deduplicationKey, now); } } } - return alreadyProcessed; + return result[0]; } public static String getDeduplicationKey(NotificationRuleTrigger trigger, NotificationRule rule) { diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java new file mode 100644 index 0000000000..826c1e1ef6 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.notification; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultNotificationDeduplicationServiceTest { + + private static final int TIMEOUT = 30; + + private DefaultNotificationDeduplicationService deduplicationService; + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + deduplicationService = new DefaultNotificationDeduplicationService(); + deduplicationService.setDeduplicationDurations(""); + cacheManager = new ConcurrentMapCacheManager(CacheConstants.SENT_NOTIFICATIONS_CACHE); + ReflectionTestUtils.setField(deduplicationService, "cacheManager", cacheManager); + } + + @Test + void testFirstTriggerIsNotDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testSecondTriggerIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testTriggerPassesAfterDeduplicationWindowExpires() { + NotificationRuleTrigger trigger = mockTrigger(50); // 50ms dedup window + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + + try { + Thread.sleep(200); // wait well past the 50ms window + } catch (InterruptedException ignored) {} + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testFutureTimestampFromExternalCacheIsDiscarded() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a timestamp 2 hours in the future into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2)); + + // Should NOT be deduplicated — future timestamp must be discarded + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testValidTimestampFromExternalCacheIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a recent timestamp into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis()); + + // Should be deduplicated — valid external cache entry + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testConcurrentTriggersProduceExactlyOneNonDeduplicated() throws Exception { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + int threadCount = 10; + CyclicBarrier barrier = new CyclicBarrier(threadCount); + List results = new CopyOnWriteArrayList<>(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + barrier.await(TIMEOUT, TimeUnit.SECONDS); + } catch (Exception ignored) {} + results.add(deduplicationService.alreadyProcessed(trigger, rule)); + }); + } + executor.shutdown(); + assertThat(executor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + + assertThat(results).hasSize(threadCount); + assertThat(results.stream().filter(r -> !r).count()) + .as("exactly one trigger should pass through deduplication") + .isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private NotificationRuleTrigger mockTrigger(long deduplicationDurationMs) { + NotificationRuleTrigger trigger = mock(NotificationRuleTrigger.class); + when(trigger.getType()).thenReturn(NotificationRuleTriggerType.RESOURCES_SHORTAGE); + when(trigger.getDeduplicationKey()).thenReturn("test:dedup:key"); + when(trigger.getDefaultDeduplicationDuration()).thenReturn(deduplicationDurationMs); + when(trigger.getDeduplicationStrategy()).thenReturn(NotificationRuleTrigger.DeduplicationStrategy.ONLY_MATCHING); + return trigger; + } + + private NotificationRule mockRule() { + NotificationRule rule = mock(NotificationRule.class); + when(rule.getDeduplicationKey()).thenReturn("rule:key"); + return rule; + } + +} From ee878ed6d0ae95e9aff45f403e07583eb46ac260 Mon Sep 17 00:00:00 2001 From: Andrii Landiak Date: Fri, 27 Mar 2026 12:32:58 +0200 Subject: [PATCH 019/123] Improve CertificateReloadManagerTest to use Awaitility --- .../service/CertificateReloadManagerTest.java | 143 +++++++----------- 1 file changed, 53 insertions(+), 90 deletions(-) diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java index ba3aa66c70..3156897210 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -28,7 +28,10 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; public class CertificateReloadManagerTest { @@ -53,24 +56,28 @@ public class CertificateReloadManagerTest { } } + private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { + Files.writeString(path, content); + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); + } + + private long mtime(Path path) throws IOException { + return Files.getLastModifiedTime(path).toMillis(); + } + @Test public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { - CountDownLatch reloadLatch = new CountDownLatch(1); AtomicInteger reloadCount = new AtomicInteger(0); - certificateReloadManager.registerWatcher("test-cert", certFile, () -> { - reloadCount.incrementAndGet(); - reloadLatch.countDown(); - }); + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n"); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); - - assertThat(reloadTriggered).isTrue(); assertThat(reloadCount.get()).isEqualTo(1); } @@ -80,9 +87,7 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -94,8 +99,6 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadCount.get()).isEqualTo(0); @@ -107,15 +110,10 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.delete(certFile); - TimeUnit.MILLISECONDS.sleep(100); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - TimeUnit.MILLISECONDS.sleep(100); - // File deletion changes checksum from real hash to "", so reload is triggered assertThat(reloadCount.get()).isEqualTo(1); } @@ -133,22 +131,19 @@ public class CertificateReloadManagerTest { Path keyFile = tempDir.resolve("test-key.pem"); Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); - CountDownLatch certReloadLatch = new CountDownLatch(1); - CountDownLatch keyReloadLatch = new CountDownLatch(1); + AtomicInteger certReloadCount = new AtomicInteger(0); + AtomicInteger keyReloadCount = new AtomicInteger(0); - certificateReloadManager.registerWatcher("test-cert", certFile, certReloadLatch::countDown); - certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadLatch::countDown); + certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet); + certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(keyFile); + writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - boolean keyReloaded = keyReloadLatch.await(2, TimeUnit.SECONDS); - - assertThat(keyReloaded).isTrue(); - assertThat(certReloadLatch.getCount()).isEqualTo(1); + assertThat(keyReloadCount.get()).isEqualTo(1); + assertThat(certReloadCount.get()).isEqualTo(0); } @Test @@ -162,14 +157,13 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); - Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(200); assertThat(reload1Count.get()).isEqualTo(1); assertThat(reload2Count.get()).isEqualTo(1); } @@ -186,14 +180,13 @@ public class CertificateReloadManagerTest { }); certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n"); - Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(200); assertThat(reload2Count.get()).isEqualTo(1); } @@ -203,44 +196,31 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.delete(certFile); - TimeUnit.MILLISECONDS.sleep(100); - ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadCount.get()).isEqualTo(1); Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); - ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - - Thread.sleep(200); assertThat(reloadCount.get()).isEqualTo(2); } @Test public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { - CountDownLatch reloadLatch = new CountDownLatch(1); AtomicInteger reloadCount = new AtomicInteger(0); - certificateReloadManager.registerWatcher("test-cert", certFile, () -> { - reloadCount.incrementAndGet(); - reloadLatch.countDown(); - }); - - TimeUnit.MILLISECONDS.sleep(100); + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + long baseline = mtime(certFile); for (int i = 0; i < 5; i++) { Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); } - TimeUnit.MILLISECONDS.sleep(100); + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> mtime(certFile) != baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - boolean reloadTriggered = reloadLatch.await(2, TimeUnit.SECONDS); - - assertThat(reloadTriggered).isTrue(); assertThat(reloadCount.get()).isEqualTo(1); } @@ -252,9 +232,8 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); for (int i = 0; i < 5; i++) { new Thread(() -> { @@ -273,7 +252,6 @@ public class CertificateReloadManagerTest { boolean completed = doneLatch.await(5, TimeUnit.SECONDS); assertThat(completed).isTrue(); - // With atomic checkAndReload, exactly one reload should happen assertThat(reloadCount.get()).isEqualTo(1); } @@ -284,14 +262,11 @@ public class CertificateReloadManagerTest { certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); - TimeUnit.MILLISECONDS.sleep(100); - - Files.writeString(certFile, originalContent); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); - Thread.sleep(200); assertThat(reloadCount.get()).isEqualTo(0); } @@ -304,11 +279,9 @@ public class CertificateReloadManagerTest { throw new RuntimeException("Persistent failure"); }); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); - // Retry up to MAX_CONSECUTIVE_FAILURES (10) + a few extra to confirm it stops for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); } @@ -328,21 +301,16 @@ public class CertificateReloadManagerTest { } }); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); - // First attempt fails ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(1); - // Fix the callback and change the file to new content shouldFail.set(0); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); - // Should reset failure counter and succeed because file content changed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(2); } @@ -359,23 +327,18 @@ public class CertificateReloadManagerTest { } }); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); - // Exhaust all retries for (int i = 0; i < 15; i++) { ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); } assertThat(reloadAttempts.get()).isEqualTo(10); - // Fix callback and change file to new content shouldFail.set(0); - TimeUnit.MILLISECONDS.sleep(100); - Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n"); - TimeUnit.MILLISECONDS.sleep(100); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); - // Should detect new content, reset counter, and succeed ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); assertThat(reloadAttempts.get()).isEqualTo(11); } From a75c008f50813e8d15628571403782d2d22cff84 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 30 Mar 2026 11:05:17 +0300 Subject: [PATCH 020/123] added tests for DefaultWebSocketService.processSubscription --- .../service/ws/DefaultWebSocketService.java | 2 +- .../ws/DefaultWebSocketServiceTest.java | 170 ++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 09651a9264..a4d7fe81cf 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -349,7 +349,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { + boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration == null) return true; diff --git a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java new file mode 100644 index 0000000000..69f918ece7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java @@ -0,0 +1,170 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ws; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.subscription.TbEntityDataSubscriptionService; +import org.thingsboard.server.service.subscription.TbLocalSubscriptionService; +import org.thingsboard.server.service.ws.notification.NotificationCommandsHandler; +import org.thingsboard.server.service.ws.telemetry.cmd.v1.AttributesSubscriptionCmd; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; + +class DefaultWebSocketServiceTest { + + DefaultWebSocketService service; + TbTenantProfileCache tenantProfileCache; + WebSocketMsgEndpoint msgEndpoint; + + @BeforeEach + void setUp() { + tenantProfileCache = mock(TbTenantProfileCache.class); + msgEndpoint = mock(WebSocketMsgEndpoint.class); + + service = new DefaultWebSocketService( + mock(TbLocalSubscriptionService.class), + mock(TbEntityDataSubscriptionService.class), + mock(NotificationCommandsHandler.class), + msgEndpoint, + mock(AccessValidator.class), + mock(AttributesService.class), + mock(TimeseriesService.class), + mock(TbServiceInfoProvider.class), + tenantProfileCache + ); + } + + // Regression test: publicUserSubscriptionsMap must be keyed by TenantId, not UserId(NULL_UUID). + // With the old UserId(NULL_UUID) key, all tenants shared one global subscription counter. + @Test + void processSubscription_publicUserSubscriptionsMap_isPerTenantNotGlobal() throws Exception { + int maxPublicSubscriptions = 2; + + TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile1 = new TenantProfile(); + profile1.createDefaultTenantProfileData(); + profile1.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile1).given(tenantProfileCache).get(tenant1); + + TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile2 = new TenantProfile(); + profile2.createDefaultTenantProfileData(); + profile2.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile2).given(tenantProfileCache).get(tenant2); + + // tenant1 fills up its quota + for (int i = 0; i < maxPublicSubscriptions; i++) { + assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-" + i), subscriptionCmd(i))) + .as("tenant1 subscription %d should be accepted", i + 1) + .isTrue(); + } + + // tenant2 must have its own independent quota — this was the bug: + // with UserId(NULL_UUID) as key all tenants shared one counter, so tenant2 would be blocked here + for (int i = 0; i < maxPublicSubscriptions; i++) { + assertThat(service.processSubscription(mockPublicSessionRef(tenant2, "t2-session-" + i), subscriptionCmd(i))) + .as("tenant2 subscription %d should not be affected by tenant1's subscriptions", i + 1) + .isTrue(); + } + + // tenant1's (maxPublicSubscriptions + 1)-th subscription must be rejected + assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-over"), subscriptionCmd(99))) + .as("tenant1 should be rejected after exceeding its limit") + .isFalse(); + + // Verify that publicUserSubscriptionsMap has separate entries per tenant + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + + assertThat(publicUserSubscriptionsMap).as("map should contain tenant1").containsKey(tenant1); + assertThat(publicUserSubscriptionsMap).as("map should contain tenant2").containsKey(tenant2); + assertThat(publicUserSubscriptionsMap).as("map must not have a single NULL_UUID entry for all tenants") + .doesNotContainKey(new TenantId(EntityId.NULL_UUID)); + + assertThat(publicUserSubscriptionsMap.get(tenant1)) + .as("tenant1 should have exactly %d subscriptions", maxPublicSubscriptions) + .hasSize(maxPublicSubscriptions); + assertThat(publicUserSubscriptionsMap.get(tenant2)) + .as("tenant2 should have exactly %d subscriptions", maxPublicSubscriptions) + .hasSize(maxPublicSubscriptions); + } + + @Test + void processSubscription_publicUserSubscriptionsMap_subscriptionIdFormat() { + int maxPublicSubscriptions = 5; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + String sessionId = "my-session-id"; + int cmdId = 42; + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); + service.processSubscription(sessionRef, subscriptionCmd(cmdId)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + + Set subs = publicUserSubscriptionsMap.get(tenantId); + assertThat(subs).hasSize(1); + assertThat(subs.iterator().next()).isEqualTo("[" + sessionId + "]:[" + cmdId + "]"); + } + + private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId, String sessionId) { + CustomerId customerId = new CustomerId(UUID.randomUUID()); + SecurityUser securityUser = mock(SecurityUser.class); + willReturn(tenantId).given(securityUser).getTenantId(); + willReturn(customerId).given(securityUser).getCustomerId(); + willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId(); + willReturn(true).given(securityUser).isCustomerUser(); + willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal(); + + WebSocketSessionRef ref = mock(WebSocketSessionRef.class); + willReturn(securityUser).given(ref).getSecurityCtx(); + willReturn(sessionId).given(ref).getSessionId(); + return ref; + } + + private AttributesSubscriptionCmd subscriptionCmd(int cmdId) { + AttributesSubscriptionCmd cmd = new AttributesSubscriptionCmd(); + cmd.setCmdId(cmdId); + return cmd; + } + +} From bf307a41bd94a7da1f6731ec669774c2c45af758 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 30 Mar 2026 11:17:02 +0300 Subject: [PATCH 021/123] added tests for DefaultWebSocketService --- .../service/ws/DefaultWebSocketService.java | 6 +- .../ws/DefaultWebSocketServiceTest.java | 107 ++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index a4d7fe81cf..27a8ca275c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -315,7 +315,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private void processSessionClose(WebSocketSessionRef sessionRef) { + void processSessionClose(WebSocketSessionRef sessionRef) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration != null) { String sessionId = "[" + sessionRef.getSessionId() + "]"; @@ -403,7 +403,9 @@ public class DefaultWebSocketService implements WebSocketService { if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { - if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { + if (cmd.isUnsubscribe()) { + publicUserSessions.remove(subId); + } else if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { publicUserSessions.add(subId); } else { log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached" diff --git a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java index 69f918ece7..a533e0369f 100644 --- a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java @@ -146,6 +146,113 @@ class DefaultWebSocketServiceTest { assertThat(subs.iterator().next()).isEqualTo("[" + sessionId + "]:[" + cmdId + "]"); } + @Test + void processSubscription_unsubscribe_removesEntryFromPublicUserSubscriptionsMap() { + int maxPublicSubscriptions = 5; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + String sessionId = "session-1"; + int cmdId = 1; + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); + + service.processSubscription(sessionRef, subscriptionCmd(cmdId)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(1); + + AttributesSubscriptionCmd unsubCmd = subscriptionCmd(cmdId); + unsubCmd.setUnsubscribe(true); + service.processSubscription(sessionRef, unsubCmd); + + assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty(); + } + + @Test + void processSubscription_unsubscribe_freesSlotForNewSubscription() { + int maxPublicSubscriptions = 1; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, "session-1"); + service.processSubscription(sessionRef, subscriptionCmd(1)); + + // slot is full — second subscription on same session should be rejected + assertThat(service.processSubscription(sessionRef, subscriptionCmd(2))).isFalse(); + + // unsubscribe cmd 1 to free the slot + AttributesSubscriptionCmd unsubCmd = subscriptionCmd(1); + unsubCmd.setUnsubscribe(true); + service.processSubscription(sessionRef, unsubCmd); + + // now a new subscription should succeed + assertThat(service.processSubscription(sessionRef, subscriptionCmd(3))) + .as("new subscription should succeed after unsubscribe freed the slot") + .isTrue(); + } + + @Test + void processSessionClose_removesAllSessionSubscriptionsFromPublicUserSubscriptionsMap() { + int maxPublicSubscriptions = 10; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + String sessionId = "closing-session"; + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); + + service.processSubscription(sessionRef, subscriptionCmd(1)); + service.processSubscription(sessionRef, subscriptionCmd(2)); + service.processSubscription(sessionRef, subscriptionCmd(3)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); + + service.processSessionClose(sessionRef); + + assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty(); + } + + @Test + void processSessionClose_onlyRemovesClosedSessionSubscriptions() { + int maxPublicSubscriptions = 10; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + WebSocketSessionRef session1 = mockPublicSessionRef(tenantId, "session-1"); + WebSocketSessionRef session2 = mockPublicSessionRef(tenantId, "session-2"); + + service.processSubscription(session1, subscriptionCmd(1)); + service.processSubscription(session1, subscriptionCmd(2)); + service.processSubscription(session2, subscriptionCmd(1)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); + + service.processSessionClose(session1); + + Set remaining = publicUserSubscriptionsMap.get(tenantId); + assertThat(remaining).hasSize(1); + assertThat(remaining).allMatch(subId -> subId.startsWith("[session-2]")); + } + private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId, String sessionId) { CustomerId customerId = new CustomerId(UUID.randomUUID()); SecurityUser securityUser = mock(SecurityUser.class); From 5deeb2ab22d75b50d427a8746d0e5dddd8ed2396 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Mon, 30 Mar 2026 14:09:17 +0300 Subject: [PATCH 022/123] added test --- .../server/dao/service/AlarmServiceTest.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index e1093e4f46..2329111e22 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.service; import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.Assert; import org.junit.Test; +import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; @@ -57,6 +58,7 @@ import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.user.UserService; @@ -64,6 +66,8 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class AlarmServiceTest extends AbstractServiceTest { @@ -987,4 +991,25 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertEquals(1, alarmsCount); } + @Test + public void testShouldFailToCreateAlarmWithBadType() { + AssetId originatorId = new AssetId(Uuids.timeBased()); + + long ts = System.currentTimeMillis(); + AlarmCreateOrUpdateActiveRequest request = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .originator(originatorId) + .type("") + .severity(AlarmSeverity.CRITICAL) + .startTs(ts).build(); + + Assertions.assertThrows(DataValidationException.class, () -> { + alarmService.createAlarm(request); + }); + + request.setType(TEST_ALARM); + AlarmApiCallResult result = alarmService.createAlarm(request); + assertThat(result.getAlarm().getId()).isNotNull(); + } + } From 2a7eab3db54ffffaaad54841af04875e415e2670 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 31 Mar 2026 13:22:19 +0300 Subject: [PATCH 023/123] Version set to 4.2.2.2-SNAPSHOT --- application/pom.xml | 2 +- common/actor/pom.xml | 2 +- common/cache/pom.xml | 2 +- common/cluster-api/pom.xml | 2 +- common/coap-server/pom.xml | 2 +- common/dao-api/pom.xml | 2 +- common/data/pom.xml | 2 +- common/discovery-api/pom.xml | 2 +- common/edge-api/pom.xml | 2 +- common/edge-api/src/main/proto/edge.proto | 1 + common/edqs/pom.xml | 2 +- common/message/pom.xml | 2 +- common/pom.xml | 2 +- common/proto/pom.xml | 2 +- common/queue/pom.xml | 2 +- common/script/pom.xml | 2 +- common/script/remote-js-client/pom.xml | 2 +- common/script/script-api/pom.xml | 2 +- common/stats/pom.xml | 2 +- common/transport/coap/pom.xml | 2 +- common/transport/http/pom.xml | 2 +- common/transport/lwm2m/pom.xml | 2 +- common/transport/mqtt/pom.xml | 2 +- common/transport/pom.xml | 2 +- common/transport/snmp/pom.xml | 2 +- common/transport/transport-api/pom.xml | 2 +- common/util/pom.xml | 2 +- common/version-control/pom.xml | 2 +- dao/pom.xml | 2 +- edqs/pom.xml | 2 +- monitoring/pom.xml | 2 +- msa/black-box-tests/pom.xml | 2 +- msa/edqs/pom.xml | 2 +- msa/js-executor/package.json | 2 +- msa/js-executor/pom.xml | 2 +- msa/monitoring/pom.xml | 2 +- msa/pom.xml | 2 +- msa/tb-node/pom.xml | 2 +- msa/tb/pom.xml | 2 +- msa/transport/coap/pom.xml | 2 +- msa/transport/http/pom.xml | 2 +- msa/transport/lwm2m/pom.xml | 2 +- msa/transport/mqtt/pom.xml | 2 +- msa/transport/pom.xml | 2 +- msa/transport/snmp/pom.xml | 2 +- msa/vc-executor-docker/pom.xml | 2 +- msa/vc-executor/pom.xml | 2 +- msa/web-ui/package.json | 2 +- msa/web-ui/pom.xml | 2 +- netty-mqtt/pom.xml | 4 ++-- pom.xml | 2 +- rest-client/pom.xml | 2 +- rule-engine/pom.xml | 2 +- rule-engine/rule-engine-api/pom.xml | 2 +- rule-engine/rule-engine-components/pom.xml | 2 +- tools/pom.xml | 2 +- transport/coap/pom.xml | 2 +- transport/http/pom.xml | 2 +- transport/lwm2m/pom.xml | 2 +- transport/mqtt/pom.xml | 2 +- transport/pom.xml | 2 +- transport/snmp/pom.xml | 2 +- ui-ngx/package.json | 2 +- ui-ngx/pom.xml | 2 +- 64 files changed, 65 insertions(+), 64 deletions(-) diff --git a/application/pom.xml b/application/pom.xml index af5d26070d..8e5e6b2564 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard application diff --git a/common/actor/pom.xml b/common/actor/pom.xml index e4d566a779..4d5fff8157 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 4d4ece7c83..f3f4f47bb9 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index 4fcde696b2..bd0c1e6016 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index d3d62ddade..55f2ee4896 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index 43f441b5a2..f2a1fabfbd 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/data/pom.xml b/common/data/pom.xml index 9e1d24ef26..e14e7072ea 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 3df2e3f204..0d75e57b9a 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 1310f2cd69..bb0d5cb192 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index e2f565c7a3..70f90cc45b 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -47,6 +47,7 @@ enum EdgeVersion { V_4_2_1_2 = 14; V_4_2_2 = 4220; V_4_2_2_1 = 4221; + V_4_2_2_2 = 4222; V_LATEST = 99999; } diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 56d2f2cb0c..4b2cd36592 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/message/pom.xml b/common/message/pom.xml index 6221bd74b3..69c4e72e2a 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/pom.xml b/common/pom.xml index f0e6936ec8..ab2388eb9d 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index dbf6a62211..023b2bc46a 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/queue/pom.xml b/common/queue/pom.xml index 0c4364c790..a7d4d5b568 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/script/pom.xml b/common/script/pom.xml index 16793dc718..3527a1c695 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 5aa9ddbc4f..6746100ef2 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index bc4c9192c5..6a55f3cc71 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT script org.thingsboard.common.script diff --git a/common/stats/pom.xml b/common/stats/pom.xml index 84a4dceee3..75d15a89d6 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index 42170bdad5..3e63520ce2 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index e832433cb3..3a06449bd9 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index b1594c1c3d..9267d74dd2 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index 5b947c806f..b63ad8a1ac 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/pom.xml b/common/transport/pom.xml index ac89c33953..d77303b40c 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 5c711fdc90..f746b58b7d 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 8fda8f4daf..10ce5ede63 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/util/pom.xml b/common/util/pom.xml index f529772166..4748d75d92 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index 3ea8054bd5..66c87eb5d1 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT common org.thingsboard.common diff --git a/dao/pom.xml b/dao/pom.xml index 6e67ea5d27..bc172eeb45 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard dao diff --git a/edqs/pom.xml b/edqs/pom.xml index c1c471ef62..9bf82c7a65 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard edqs diff --git a/monitoring/pom.xml b/monitoring/pom.xml index e15b603449..2601ac47fe 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 8e4dcdc6c4..c06e98b45e 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 3377cff6c6..f85e9b7691 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index a09596e6ac..b096a3ae99 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.2.2.1", + "version": "4.2.2.2", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 87b3657d4a..7e01cfeadd 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index aded630179..ab484e7df8 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa diff --git a/msa/pom.xml b/msa/pom.xml index d56a7aa265..ac7b7b70c3 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard msa diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 8272815640..3dd84135cb 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 33e7abeabf..5c10adf67e 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 6517d8b466..f081e6fd46 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 4c3445c4fc..78e445fda8 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index e106d81641..d65d07f9a9 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index f2f4f1e531..8dfe14b3a6 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.msa.transport diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index ae50472021..aa10ff1184 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index a1e024c735..fb344e3242 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT org.thingsboard.msa.transport diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index 5713d9a142..0bc90c6ac4 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index c476d1725c..a0c023b5f8 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index 1a956617cd..c51aa6ed05 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,7 +1,7 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.2.2.1", + "version": "4.2.2.2", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 2307818944..4420183cd7 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT msa org.thingsboard.msa diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 615a0ed728..4b9b9fa21e 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard netty-mqtt - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index 9dd86011be..001a1bcc0d 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT pom Thingsboard diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 787a28a497..d108c49cda 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard rest-client diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index ff443deb61..de69670863 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index bcc68f2049..3810c46a99 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index c326b93b7c..a156bb22a2 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/tools/pom.xml b/tools/pom.xml index 339be85998..a2820d48f6 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard tools diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 6e67ff417e..723f9a1dd2 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 39b57240fb..d4e19705c9 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index ebe1e6bcca..fac60f7edc 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 856df50aa0..7b61c03200 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport org.thingsboard.transport diff --git a/transport/pom.xml b/transport/pom.xml index 96ddd89a8f..3645cf7d73 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index f57e99b113..683c0e3e2d 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT transport diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 42f4e21c0f..b88a0583e2 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.2.2.1", + "version": "4.2.2.2", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 7cbd38e648..b77cb8c4fb 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.2.2.1-SNAPSHOT + 4.2.2.2-SNAPSHOT thingsboard org.thingsboard From a9681b9a81ce65c9c6a8b95c26d2b6a55a3e1af5 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 31 Mar 2026 14:18:45 +0300 Subject: [PATCH 024/123] Bump Node.js version from 22.18.0 to 22.22.2 --- msa/js-executor/docker/Dockerfile | 2 +- msa/js-executor/pom.xml | 2 +- msa/web-ui/docker/Dockerfile | 2 +- msa/web-ui/pom.xml | 2 +- ui-ngx/pom.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile index 1ef157fdd9..736bd56529 100644 --- a/msa/js-executor/docker/Dockerfile +++ b/msa/js-executor/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/node:22.18.0-bookworm-slim +FROM thingsboard/node:22.22.2-bookworm-slim ENV NODE_ENV production ENV DOCKER_MODE true diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 7e01cfeadd..f5ea04fac3 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -70,7 +70,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile index 063374a478..b87885b45d 100644 --- a/msa/web-ui/docker/Dockerfile +++ b/msa/web-ui/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/node:22.18.0-bookworm-slim +FROM thingsboard/node:22.22.2-bookworm-slim ENV NODE_ENV production ENV DOCKER_MODE true diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 4420183cd7..00c1703dcd 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -79,7 +79,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index b77cb8c4fb..cc58f728a0 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -56,7 +56,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 From de87d1f248df954d72aea7847ad693033ebfb406 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 31 Mar 2026 15:28:37 +0300 Subject: [PATCH 025/123] updated efento proto files according to latest release, added support of new modem types --- .../efento/CoapEfentoTransportResource.java | 125 ++-- .../src/main/proto/efento/proto_config.proto | 673 +++++++++++------- .../proto/efento/proto_config_types.proto | 125 ++++ .../main/proto/efento/proto_device_info.proto | 409 +++++++---- .../efento/proto_measurement_types.proto | 185 +++-- .../proto/efento/proto_measurements.proto | 217 ++++-- .../src/main/proto/efento/proto_rule.proto | 407 ++++++----- .../CoapEfentoTransportResourceTest.java | 620 +++++++++++++++- 8 files changed, 1959 insertions(+), 802 deletions(-) create mode 100644 common/transport/coap/src/main/proto/efento/proto_config_types.proto diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index 708263b26a..3a434b963b 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -245,7 +245,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } List getEfentoMeasurements(MeasurementsProtos.ProtoMeasurements protoMeasurements, UUID sessionId) { - String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNum().toByteArray()); + String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNumber().toByteArray()); boolean batteryStatus = protoMeasurements.getBatteryStatus(); int measurementPeriodBase = protoMeasurements.getMeasurementPeriodBase(); int measurementPeriodFactor = protoMeasurements.getMeasurementPeriodFactor(); @@ -446,7 +446,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } } - private EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { + EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { JsonObject values = new JsonObject(); values.addProperty("sw_version", protoDeviceInfo.getSwVersion()); @@ -476,41 +476,86 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { //modem info DeviceInfoProtos.ProtoModem modem = protoDeviceInfo.getModem(); - values.addProperty("modem_types", modem.getType().toString()); - values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); - values.addProperty("sc_EARFCN", modem.getParameters(1)); - values.addProperty("sc_PCI", modem.getParameters(2)); - values.addProperty("sc_Cell_id", modem.getParameters(3)); - values.addProperty("sc_RSRP", modem.getParameters(4)); - values.addProperty("sc_RSRQ", modem.getParameters(5)); - values.addProperty("sc_RSSI", modem.getParameters(6)); - values.addProperty("sc_SINR", modem.getParameters(7)); - values.addProperty("sc_Band", modem.getParameters(8)); - values.addProperty("sc_TAC", modem.getParameters(9)); - values.addProperty("sc_ECL", modem.getParameters(10)); - values.addProperty("sc_TX_PWR", modem.getParameters(11)); - values.addProperty("op_mode", modem.getParameters(12)); - values.addProperty("nc_EARFCN", modem.getParameters(13)); - values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); - values.addProperty("nc_PCI", modem.getParameters(15)); - values.addProperty("nc_RSRP", modem.getParameters(16)); - values.addProperty("RLC_UL_BLER", modem.getParameters(17)); - values.addProperty("RLC_DL_BLER", modem.getParameters(18)); - values.addProperty("MAC_UL_BLER", modem.getParameters(19)); - values.addProperty("MAC_DL_BLER", modem.getParameters(20)); - values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); - values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); - values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); - values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); - values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); - values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); - values.addProperty("RLC_UL_tput", modem.getParameters(27)); - values.addProperty("RLC_DL_tput", modem.getParameters(28)); - values.addProperty("MAC_UL_tput", modem.getParameters(29)); - values.addProperty("MAC_DL_tput", modem.getParameters(30)); - values.addProperty("sleep_duration", modem.getParameters(31)); - values.addProperty("rx_time", modem.getParameters(32)); - values.addProperty("tx_time", modem.getParameters(33)); + DeviceInfoProtos.ModemType modemType = modem.getType(); + values.addProperty("modem_types", modemType.toString()); + values.addProperty("sim_card_identification", modem.getSimCardIdentification()); + values.addProperty("firmware_version", modem.getFirmwareVersion().toString()); + values.addProperty("modem_identification", modem.getModemIdentification()); + if (modem.getModemStatisticsCount() >= 4) { + values.addProperty("modem_transmissions_count", modem.getModemStatistics(0)); + values.addProperty("modem_time_since_last_devinfo", modem.getModemStatistics(1)); + values.addProperty("modem_total_psm_time", modem.getModemStatistics(2)); + values.addProperty("modem_total_active_time", modem.getModemStatistics(3)); + } + switch (modemType) { + case MODEM_TYPE_BC660: + values.addProperty("sc_EARFCN", modem.getParameters(0)); + values.addProperty("sc_EARNFCN_offset", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_PCI", modem.getParameters(14)); + values.addProperty("nc_RSRP", modem.getParameters(15)); + values.addProperty("nc_RSRQ", modem.getParameters(16)); + values.addProperty("sleep_duration", modem.getParameters(17)); + values.addProperty("rx_time", modem.getParameters(18)); + values.addProperty("tx_time", modem.getParameters(19)); + values.addProperty("PLMN_state", modem.getParameters(20)); + values.addProperty("select_PLMN", modem.getParameters(21)); + break; + case MODEM_TYPE_SHARED_MODEM: + values.addProperty("RSRP", modem.getParameters(0)); + values.addProperty("RSRQ", modem.getParameters(1)); + values.addProperty("RSSI", modem.getParameters(2)); + values.addProperty("SINR", modem.getParameters(3)); + break; + default: + // MODEM_TYPE_UNSPECIFIED, MODEM_TYPE_BC66, MODEM_TYPE_BC66NA + values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); + values.addProperty("sc_EARFCN", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); + values.addProperty("nc_PCI", modem.getParameters(15)); + values.addProperty("nc_RSRP", modem.getParameters(16)); + values.addProperty("RLC_UL_BLER", modem.getParameters(17)); + values.addProperty("RLC_DL_BLER", modem.getParameters(18)); + values.addProperty("MAC_UL_BLER", modem.getParameters(19)); + values.addProperty("MAC_DL_BLER", modem.getParameters(20)); + values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); + values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); + values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); + values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); + values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); + values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); + values.addProperty("RLC_UL_tput", modem.getParameters(27)); + values.addProperty("RLC_DL_tput", modem.getParameters(28)); + values.addProperty("MAC_UL_tput", modem.getParameters(29)); + values.addProperty("MAC_DL_tput", modem.getParameters(30)); + values.addProperty("sleep_duration", modem.getParameters(31)); + values.addProperty("rx_time", modem.getParameters(32)); + values.addProperty("tx_time", modem.getParameters(33)); + break; + } //Runtime info DeviceInfoProtos.ProtoRuntime runtimeInfo = protoDeviceInfo.getRuntimeInfo(); @@ -521,7 +566,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { values.addProperty("counter_of_non_confirmable_messages_attempts", runtimeInfo.getMessageCounters(1)); values.addProperty("counter_of_succeeded_messages", runtimeInfo.getMessageCounters(2)); values.addProperty("min_battery_mcu_temp", runtimeInfo.getMinBatteryMcuTemperature()); - values.addProperty("min_battery_voltage", runtimeInfo.getMinBatteryVoltage()); + values.addProperty("min_battery_voltage", runtimeInfo.getBatteryVoltage()); values.addProperty("min_mcu_temp", runtimeInfo.getMinMcuTemperature()); values.addProperty("runtime_errors", runtimeInfo.getRuntimeErrorsCount()); values.addProperty("up_time", runtimeInfo.getUpTime()); @@ -529,8 +574,8 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { return new EfentoTelemetry(System.currentTimeMillis(), values); } - private JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { - return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(2))); + JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { + return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(0))); } private static String getDate(long seconds) { diff --git a/common/transport/coap/src/main/proto/efento/proto_config.proto b/common/transport/coap/src/main/proto/efento/proto_config.proto index ae6d7902b3..1052d338e7 100644 --- a/common/transport/coap/src/main/proto/efento/proto_config.proto +++ b/common/transport/coap/src/main/proto/efento/proto_config.proto @@ -15,338 +15,471 @@ */ syntax = "proto3"; -import "efento/proto_measurement_types.proto"; import "efento/proto_rule.proto"; +import "efento/proto_config_types.proto"; +import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ConfigProtos"; -/* Message containing optional channels control parameters */ -message ProtoOutputControlState { - - /* Channel index */ - uint32 channel_index = 1; - - /* Channel state ON/OFF. Range (1 - OFF; 2 - ON) */ - uint32 channel_state = 2; -} - -/* Message containing request data for accessing calibration parameters */ -message ProtoCalibrationParameters { - - /* Request details. Bitmask: */ - /* - calibration_request[0:2] - requested channel number. */ - uint32 calibration_request = 1; - - /* Assignment of a channel. */ - uint32 channel_assignment = 2; - - /* Table of calibration parameters. Max size = 8. */ - repeated int32 parameters = 3; -} - -enum BleAdvertisingPeriodMode { - - /* Invalid value */ - BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; +message ProtoConfig { - /* Default behavior - faster advertising when measurement period is < 15s. */ - BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* User-configured normal interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + reserved 1,48; - /* User-configured fast interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_FAST = 3; -} + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ -/* Message containing BLE advertising period configuration */ -message ProtoBleAdvertisingPeriod { + /* Serial number of the device. * + * Length: 6 bytes. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + bytes serial_number = 25; - /* BLE advertising mode: */ - /* - 1: Default, BLE advertising interval is set to 1022.5ms or some lower value, based on continuous measurement period. */ - /* - 2: Normal, uses user-configured value from 'normal' field. */ - /* - 3: Fast, uses user-configured value from 'fast' field (must be lower than or equal to 'normal' field). */ - BleAdvertisingPeriodMode mode = 1; + /* Configuration payload split information: * + * - Values < 0 - Payload split, expect another part of the payload in the next message. * + * The absolute value indicates an index of the current message * + * - Values = 0 - Payload not split * + * - Values > 0 - Last part of the split payload, the value indicates the total number of the messages sent * + * This field is only sent by the device. * + * Status: In use [06.08.00 - LATEST] */ + sint32 payload_split_info = 44; - /* BLE advertising interval when in normal mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 normal = 2; + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 21; + + /* Timestamp when the new configuration was set. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash_timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_hash_timestamp = 39; + + /* Configuration errors. * + * Up to 20 error codes supported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as errors [06.00 - 06.xx.xx] */ + repeated uint32 configuration_errors = 20; + + /* Timestamp when a new configuration error was reported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_error_timestamp = 38; + + /* Measurement channel types. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + repeated MeasurementType channel_types = 27; - /* BLE advertising interval when in fast mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 fast = 3; -} + /* NvM status: * + * - 1 - Defaults restored on CRC error: default sensor configuration restored due to the non-volatile memory configuration * + * data integrity failure * + * - 2 - NvM initialization error: changes in the sensor configuration won't be stored in the non-volatile memory, a power * + * reset of the sensor is required * + * When empty field is sent by the sensor NvM status is OK. * + * NvM status can be cleared by sending to the sensor value 0x7F. * + * Status: In use [07.00.00 - LATEST] */ + uint32 nvm_status = 62; + + /* SERVER STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Current time in seconds since 1st of January 1970 (epoch time). * + * This field is only sent by the server. * + * Status: In use [06.00 - LATEST] */ + uint32 current_time = 8; -/* Main message sent in the payload. Each field in this message is independent of the others - only parameters that should be */ -/* changed need to be sent in the payload. */ -/* If the value of a selected parameter shall not be changed, do not include it in the payload */ -message ProtoConfig { + /* REQUEST FIELDS ----------------------------------------------------------------------------------------------------------- */ + + /* Specifies whether the device should accept the configuration without functional testing (e.g., network connection). * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as accept_without_testing [06.00 - 06.xx.xx] */ + bool accept_without_testing_request = 22; + + /* Specifies whether to send the configuration from the sensor to the configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_configuration [06.00 - 06.xx.xx] */ + bool configuration_request = 19; + + /* Specifies whether to send the device information from the sensor to the device information endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_device_info [06.00 - 06.xx.xx] */ + bool device_info_request = 6; + + /* Specifies whether to send the extended configuration from the sensor to the extended configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool extended_configuration_request = 63; + + /* Specifies, if software update is available. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_fw_update [06.00 - 06.xx.xx] */ + bool update_software_request = 7; + + /* Device will clear all runtime errors. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_runtime_errors_clear [06.02 - 06.xx.xx] */ + bool clear_runtime_errors_request = 37; + + /* Device will power off its cellular modem for requested number of seconds. * + * Range: [60:604800] (1 minute : 7 days) * + * This field is only sent by the user/server. * + * Status: In use [06.00 - LATEST] */ + uint32 disable_modem_request = 18; - /* DEPRECATED - Used for backward compatibility with fw versions 5.x */ - /* repeated Threshold thresholds = 1; */ - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Specifies, if the modem firmware update is available. * + * String up to 48 characters: * + * - DFOTA URL - For use by BC66/BC660 modem. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as modem_update_request [06.08.00 - 06.xx.xx] */ + string update_modem_request = 45; + + /* Device will erase all measurements from memory. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as memory_reset_request [06.00 - 06.xx.xx] */ + bool reset_memory_request = 30; + + /* Device will restart the collection of memory statistics. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool restart_memory_stats_request = 64; + + /* Specifies whether to send measurements from the sensor starting at the specified timestamp. * + * Tiemstamp in seconds since 1st of January 1970 (epoch time). * + * All previous measurements will be marked as sent. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + uint32 data_transfer_start_timestamp_request = 65; + + /* Device will set new calibration parameters or will send the current set of parameters to the configuration endpoint. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + ProtoCalibrationParametersRequest calibration_parameters_request = 49; + + /* Device will set the new output state of the output control channel pins. * + * Up to 3 channels supported in the one request. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as output_control_state_request [06.13.00/06.21.00 - 06.xx.xx] */ + repeated ProtoOutputControlState set_output_control_state_request = 58; + + /* MEASUREMENTS CONFIGURATION ----------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 2; - /* Measurement period factor */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Measurement period factor. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 26; - /* Transmission interval in seconds. Range: [60:604800] */ - uint32 transmission_interval = 3; + /* BLUETOOTH CONFIGURATION -------------------------------------------------------------------------------------------------- */ - /* BLE turnoff time in seconds. Once receiving this setting, BLE will be switched off after the set number of seconds. */ - /* If BLE is already switched off, it will switch on for the set number of seconds and switch off afterwards. */ - /* Range [60:604800] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always on */ + /* Bluetooth turn-off time: * + * - [60:604800] - Time in seconds after which Bluetooth is turned off * + * - 0xFFFFFFFF - Bluetooth is always on * + * When this setting is received, Bluetooth is turned off after the set number of seconds. * + * If Bluetooth is already off, it will turn on for the set number of seconds and then turn off. * + * Group: Bluetooth Turn-Off * + * Status: In use [06.00 - LATEST] */ uint32 ble_turnoff_time = 4; - /* ACK interval in seconds */ - /* Range [180:2592000] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always request ACK */ - uint32 ack_interval = 5; - - /* Specifies, if the additional device info is requested. If true, sensor will send a message to endpoint '/i' with the */ - /* device info. This field is only sent by server */ - bool request_device_info = 6; - - /* Specifies, if software update is available. This field is only sent by server */ - bool request_fw_update = 7; - - /* Current time in seconds sine 1st of January 1970 (epoch time). */ - uint32 current_time = 8; - - /* NB-IoT transfer limit */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit = 9; - - /* NB-IoT transfer limit timer in seconds */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit_timer = 10; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the data (measurements) server */ - /* The IP or URL of the data server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the data (measurements) server */ - /* For example, use "18.184.24.239" */ - string data_server_ip = 11; - - /* Data (measurements) server port */ - /* Range: [1:65535] */ - uint32 data_server_port = 12; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the update server */ - /* The IP or URL of the update server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the update server */ - /* For example, use "18.184.24.239" */ - string update_server_ip = 13; - - /* Update server port for UDP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_udp = 14; - - /* Update server port for CoAP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_coap = 15; - - /* APN as string. Max length 49 */ - /* String with special character 0x7F (DEL) only indicates that automatic apn is turn on */ - string apn = 16; + /* Bluetooth Tx power level. * + * Value is the index of the absolute value of the Tx power, which depends on the BLE module. * + * Range: [1:4] * + * Group: Bluetooth Tx Power * + * Status: In use [06.02 - LATEST] */ + uint32 ble_tx_power_level = 36; - /* PLMN selection */ - /* Range: [100:999999] */ - /* 0xFFFFFFFF or 1000000 - automatic selection */ - uint32 plmn_selection = 17; + /* Encryption key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables encryption. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the encryption key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Encryption * + * Status: In use [06.11.00 - LATEST] */ + bytes encryption_key = 54; - /* Device will power off its cellular modem for requested number of seconds. */ - /* Range: [60:604800] (1 minute : 7 days) */ - /* This field is only sent by server */ - uint32 disable_modem_request = 18; + /* Bluetooth advertising period. * + * Group: Bluetooth Advertising Period * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + ProtoBleAdvertisingPeriod ble_advertising_period = 59; - /* If set, the device will send its configuration to the endpoint '/c' as a confirmable message */ - /* This field is only sent by server */ - bool request_configuration = 19; + /* Advertisement manufacturer specific data format. * + * Group: Advertisement Manufacturer Data Format * + * Status: In use [07.00.00 - LATEST] */ + AdvertisementManufacturerDataFormat advertisement_manufacturer_data_format = 60; - /* Device's error codes. */ - /* This field is only sent by device */ - repeated uint32 errors = 20; + /* EDGE LOGIC CONFIGURATION ------------------------------------------------------------------------------------------------- */ - /* Identifier of current configuration - Every change of the configuration results in change of the value of this field */ - /* This field is only sent by device */ - uint32 hash = 21; + /* Edge logic rules set on the device. * + * Up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]). * + * Group: Edge Logic Rule No. # * + * Status: In use [06.00 - LATEST] */ + repeated ProtoRule rules = 28; - /* If true, the device will accept the configuration without functional testing (eg. network connection) */ - bool accept_without_testing = 22; + /* Calendars set on the device. * + * Up to 6 calendars supported. * + * Group: Calendar No. # * + * Status: In use [06.08.00 - LATEST] */ + repeated ProtoCalendar calendars = 47; - /* Cloud token configuration: */ - /* - 1: cloud token set to the value of cloud_token field */ - /* - 2: cloud token set to IMEI of the cellular module */ - /* - 255: do not send cloud_token field */ - uint32 cloud_token_config = 23; + /* SERVER COMMUNICATION CONFIGURATION --------------------------------------------------------------------------------------- */ - /* Cloud token that should be sent with each measurement frame */ - string cloud_token = 24; + /* Transmission interval in seconds. * + * Range: [60:604800] * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 transmission_interval = 3; - /* Serial number of the device */ - /* This field is only sent by device */ - bytes serial_number = 25; + /* ACK interval: * + * - [180:2592000] - Time in seconds after which the device will request an ACK * + * - 0xFFFFFFFF - Always request ACK * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 ack_interval = 5; - /* Type of channel */ - /* This field is only sent by device */ - repeated MeasurementType channel_types = 27; + /* Server transfer limit: * + * - [1:65534] - Number of transfers * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit = 9; - /* Edge logic rules set on the device. Up to 12 rules are supported */ - repeated ProtoRule rules = 28; + /* Server transfer limit timer: * + * - [1:65534] - Time in seconds after which the transfer is renewed * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit_timer = 10; - /* Supervision period */ - /* Range: [180:604800] */ - /* 0xFFFFFFFF - Functionality disabled */ + /* Server supervision period: * + * - [180:604800] - Time in seconds after which the device resets itself if there is no communication with the server * + * - 0xFFFFFFFF - Server supervision disabled * + * Group: Server Supervision * + * Status: In use [06.00 - LATEST] */ uint32 supervision_period = 29; - /* If true, sensor's measurement memory will be erased */ - bool memory_reset_request = 30; + /* DATA SERVER CONFIGURATION ------------------------------------------------------------------------------------------------ */ - /* Bytes 0-4 - Band selection mask. Mask = 1 << position */ - /* Band | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | */ - /* Position: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | */ - /* example: To enable band 3, 8 and 20 set to (1 << 2) + (1 << 5) + (1 << 11) = 2084 */ - uint32 modem_bands_mask = 31; + /* Data server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string data_server_ip = 11; + + /* Data server port. * + * Range: [1:65535] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 data_server_port = 12; - /* Data endpoint (string - max length 16) */ + /* Data endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string data_endpoint = 32; - /* Configuration endpoint (string - max length 16) */ + /* Configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string configuration_endpoint = 33; - /* Device info endpoint (string - max length 16) */ + /* Extended configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] */ + string extended_configuration_endpoint = 61; + + /* Device information endpoint. * + * String up to 11 characters. * + * Group: Device Information Endpoint * + * Status: In use [06.02 - LATEST] */ string device_info_endpoint = 34; - /* Time endpoint (string - max length 16) */ + /* Time endpoint. * + * String up to 11 characters. * + * Group: Time Endpoint * + * Status: In use [06.02 - LATEST] */ string time_endpoint = 35; - /* Bluetooth TX power level. Value is the index of the absolute value of TX power, that depends on the BLE module */ - /* Range: [1:4] */ - uint32 ble_tx_power_level = 36; + /* UPDATE SERVER CONFIGURATION ---------------------------------------------------------------------------------------------- */ - /* Deprecated field */ - /* If true, the sensor's runtime errors will be cleared */ - bool request_runtime_errors_clear = 37; + /* Update server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + string update_server_ip = 13; - /* Timestamp when a new error code was reported */ - uint32 error_timestamp = 38; + /* Update server port for UDP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_udp = 14; - /* Timestamp when the new configuration was set */ - uint32 hash_timestamp = 39; + /* Update server port for CoAP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_coap = 15; - /* Cloud token CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing cloud token */ - /* - 65000 - cloud token sent in the payload */ - uint32 cloud_token_coap_option = 40; + /* CLOUD CONFIGURATION ------------------------------------------------------------------------------------------------------ */ - /* ECDSA payload signature CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing ECDSA payload signature */ - /* - 65000 - no payload signature in CoAP option */ - uint32 payload_signature_coap_option = 41; + /* Cloud token configuration: * + * - 1 - Cloud token set to the value of the 'cloud_token' field * + * - 2 - Cloud token set to the modem identification (IMEI for cellular modems) * + * - 255 - Do not send 'cloud_token' field * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + uint32 cloud_token_config = 23; - /* DNS server IP address grouped in the array as four octets. Set 255.255.255.255 to use a network DNS server */ - /* Note: when setting less than four octets the remaining will be filled with zeros. */ - repeated uint32 dns_server_ip = 42; + /* Cloud token that should be sent with each measurement frame. * + * String up to 36 characters. * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + string cloud_token = 24; - /* DNS TTL configuration: */ - /* - [1:864000] - custom TTL in seconds (additionally, the DNS request when communication has failed) */ - /* - 864001 - accept TTL from the DNS server (additionally, the DNS request when communication has failed) */ - /* - 864002 - DNS request is only after communication failed */ - uint32 dns_ttl_config = 43; + /* Cloud token CoAP option ID: * + * - [1:64999] - CoAP option ID with cloud token * + * - 65000 - Cloud token sent in the payload * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 cloud_token_coap_option = 40; - /* Configuration payload split information. Information about dividing the payload into parts */ - /* values < 0 - payload has been split, expect another part of the payload in the next message. */ - /* The absolute value indicates an index of the current message. */ - /* value = 0 - payload has not been splitted */ - /* values > 0 - last part of the split payload, the value indicates the total number of the messages sent */ - sint32 payload_split_info = 44; + /* ECDSA payload signature CoAP option ID: * + * - [1:64999] - CoAP option ID with payload signature * + * - 65000 - Payload signature is not sent * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 payload_signature_coap_option = 41; - /* Modem update request (string - max length 48) */ - /* This field is only sent by server */ - /* For BC66 module, this field is a DFOTA URL */ - string modem_update_request = 45; + /* Modem identification CoAP option ID: * + * - [1:64999] - CoAP option ID with modem identification * + * - 65000 - Modem identification is not sent * + * Group: Cloud Token * + * Status: In use [07.00.00 - LATEST] */ + uint32 modem_identification_coap_option = 52; - /* Cellular configuration parameters. */ - /* 1st item - Number of used cellular parameters */ - /* 2nd - 12th items - Cellular parameters */ - repeated uint32 cellular_config_params = 46; + /* NETWORK CONFIGURATION ---------------------------------------------------------------------------------------------------- */ - /* Calendar configuration. Up to 6 calendars are supported */ - repeated ProtoCalendar calendars = 47; + /* DNS server IP address. * + * Grouped in the array as four octets. Set 255.255.255.255 to use a cellular network DNS server. * + * Note: when setting less than four octets the remaining will be filled with zeros. * + * Group: Data Server * + * Status: In use [06.07.00 - LATEST] */ + repeated uint32 dns_server_ip = 42; - /* DEPRECATED - Used for backward compatibility */ - reserved 48; - - /* Set/get calibration parameters for single channel. */ - ProtoCalibrationParameters calibration_parameters_request = 49; - - /* LED behaviour configuration: */ - /* Period of LEDs flashing (5-600 seconds in 5 seconds resolution): */ - /* - led_config[0] - green LED */ - /* - led_config[1] - red LED */ - /* Time from entering the normal state, after which the LED indication is turned off */ - /* (0-240 minutes in 1 minute resolution, or 255 for always turned on): */ - /* - led_config[2] - flashing red led on communication problem */ - /* - led_config[3] - flashing red led on a sensor problem */ - /* - led_config[4] - flashing red led on a low power */ - /* - led_config[5] - flashing green led on measurement */ - /* - led_config[6] - flashing green led on transmission */ - /* - led_config[7] - flashing green led to indicate sensor's proper operation */ - /* - led_config[8] - Blink duration (20-1000ms in 5 ms resolution) */ - repeated uint32 led_config = 50; + /* DNS TTL configuration: * + * - [1:864000] - Custom TTL in seconds (if communication fails, DNS is also queried) * + * - 864001 - Accept TTL from the DNS server (if communication fails, DNS is also queried) * + * - 864002 - DNS query only after communication failure * + * Group: DNS * + * Status: In use [06.07.00 - LATEST] */ + uint32 dns_ttl_config = 43; - /* Network troubleshooting configuration, if bluetooth is turned off and communication with the server is faulty, */ - /* bluetooth will be automatically turned on until the connection is stabilized */ - /* - 1: network troubleshooting disabled */ - /* - 2: network troubleshooting enabled */ + /* Network troubleshooting: + * - 1 - Network troubleshooting disabled * + * - 2 - Network troubleshooting enabled * + * If Bluetooth is turned off and communication with the server is faulty, Bluetooth will be automatically turned on until * + * the connection is stabilized. * + * Group: Network * + * Status: In use [06.10.00 - LATEST] */ uint32 network_troubleshooting = 51; - /* Reserved by gateway client */ - reserved 52, 53; - - /* Encryption key configuration. Sensor sends in this field two last bytes of SHA256 hash calculated from its current */ - /* encryption_key configuration. When encryption key is disabled one byte 0x7F (DEL) is sent. */ - /* Max length: 16 bytes. */ - /* 0x7F - encryption key disabled. */ - bytes encryption_key = 54; - - /* User name as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic user name is turn on */ - /* User name can only be set to custom value if apn has been configured (is not automatic) */ - string apn_user_name = 55; + /* Network key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables network key. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the network key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Local Network * + * Status: In use [07.00.00 - LATEST] */ + bytes network_key = 53; + + /* MODEM CONFIGURATION ------------------------------------------------------------------------------------------------------ */ + + /* APN (Access Point Name). * + * String up to 49 characters. * + * A string containing only the special character 0x7F (DEL) indicates that automatic APN is enabled. * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string apn = 16; - /* Password as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic password is turn on */ - /* Password can only be set to custom value if apn_user_name has been configured (is not automatic) */ + /* APN username. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the username is not being used. * + * The username can only be set to a custom value if APN has been configured. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] / Previously as apn_user_name [06.11.00 - 06.xx.xx] */ + string apn_username = 55; + + /* APN password. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the password is not being used. * + * The password can only be set to a custom value if APN username has been configured. * + * Group: Data Server * + * Status: In use [06.11.00 - LATEST] */ string apn_password = 56; - /* Reserved by versions above 06.20.00 */ - reserved 57; + /* PLMN selection: * + * - [100:999999] - Selected operator code * + * - 1000000 - Automatic selection * + * - 0xFFFFFFFF - Automatic selection (legacy) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 plmn_selection = 17; - /* Control output state on channel pin. Maximal number of requests equals 3 */ - /* This field is only sent by server */ - repeated ProtoOutputControlState output_control_state_request = 58; + /* Modem bands mask. * + * 4-byte bit mask, where each bit represents a specific band: * + * Band: | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | 70 | * + * Bit: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | * + * Example: To enable band 3, 8 and 20 set to 2084 = (1 << 2) + (1 << 5) + (1 << 11) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 modem_bands_mask = 31; - /* BLE advertising period configuration. */ - ProtoBleAdvertisingPeriod ble_advertising_period = 59; -} + /* Modem configuration parameters. * + * 1st item - The number of cellular parameters that are used (depending on the modem in use). * + * 2nd - 16th item - Cellular parameters. * + * Group: Cellular Configuration * + * Status: In use [06.08.00 - LATEST] */ + repeated uint32 cellular_config_params = 46; + + /* Network search schema. * + * Group: Network Search * + * Status: In use [06.20.00 - LATEST] */ + ProtoNetworkSearch network_search = 57; + + /* USER INTERFACE CONFIGURATION --------------------------------------------------------------------------------------------- */ + + /* LEDs behavior. * + * Period of LED blinking in 5-second unit (value is multiplied by 5). Range: [1:120]: * + * - 1st item - Period of GREEN LED blinking * + * - 2nd item - Period of RED LED blinking * + * Time elapsing from the entry into the normal state, after which the LED indicator is switched off, in minutes. * + * Range: [0:240; 255]. Value of 255 means always on: * + * - 3rd item - Blinking RED LED on communication problem * + * - 4th item - Blinking RED LED on a sensor problem * + * - 5th item - Blinking RED LED on a low power * + * - 6th item - Blinking GREEN LED on measurement * + * - 7th item - Blinking GREEN LED on transmission * + * - 8th item - Blinking GREEN LED to indicate sensor's proper operation * + * 9th item - Duration of LED blinking in 5-millisecond unit. Range: [4:200] * + * Group: LED * + * Status: In use [06.10.00 - LATEST] */ + repeated uint32 led_config = 50; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_config_types.proto b/common/transport/coap/src/main/proto/efento/proto_config_types.proto new file mode 100644 index 0000000000..44fb9197a4 --- /dev/null +++ b/common/transport/coap/src/main/proto/efento/proto_config_types.proto @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; + +option java_package = "org.thingsboard.server.gen.transport.coap"; +option java_outer_classname = "ConfigTypesProtos"; + +message ProtoCalibrationParametersRequest { + + /* Request details. * + * Bitmask: * + * - Bit 0:2 - Requested channel number, range: [1:6] * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + uint32 calibration_request = 1; + + /* Channel assignment - the sensor code. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + uint32 channel_assignment = 2; + + /* Channel calibration parameters. * + * Up to 8 parameters supported. * + * If this field is empty, the sensor will send the current set of parameters. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + repeated int32 parameters = 3; +} + +message ProtoOutputControlState { + + /* Channel index. * + * Range: [0:5] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 channel_index = 1; + + /* Channel output state: * + * - 1 - OFF * + * - 2 - ON * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 channel_state = 2; +} + +enum BleAdvertisingPeriodMode { + + /* Invalid value. */ + BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; + + /* Default mode. * + * Bluetooth advertising interval is set to 1022.5ms or a lower value, based on the continuous measurement period. */ + BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + + /* Normal mode. * + * Uses the value configured by the user from the 'normal' field. */ + BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + + /* Fast mode. * + * Uses the value configured by the user from the 'fast' field. */ + BLE_ADVERTISING_PERIOD_MODE_FAST = 3; +} + +message ProtoBleAdvertisingPeriod { + + /* Bluetooth advertising mode. * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + BleAdvertisingPeriodMode mode = 1; + + /* Bluetooth advertising interval in normal mode, configured in steps of 0.625 ms. * + * Range: [32:16384] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 normal = 2; + + /* Bluetooth advertising interval in fast mode, configured in steps of 0.625 ms. * + * Range: [32:16384] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 fast = 3; +} + +enum AdvertisementManufacturerDataFormat { + + /* Invalid value. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_UNSPECIFIED = 0; + + /* Advertisement manufacturer specific data format 3. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V3 = 1; + + /* Advertisement manufacturer specific data format 5. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V5 = 2; +} + +message ProtoNetworkSearch { + + /* Timing schema, if successful registration since the last reset. * + * Length: 6 items. * + * 1st - 6th item - Time in minutes. Range: [1:255]. * + * Status: In use [06.20.00 - LATEST] */ + repeated uint32 time_schema_last_registration_ok = 1; + + /* Timing schema, if no successful registration since the last reset. * + * Length: 6 items. * + * 1st - 6th item - Time in minutes. Range: [1:255]. * + * Status: In use [06.20.00 - LATEST] */ + repeated uint32 time_schema_last_registration_not_ok = 2; + + /* Disable base period in minutes. * + * Disable time = 'disable_period_base' * counter (from 1 to 'counter_max'). * + * Range: [1:255] * + * Status: In use [06.20.00 - LATEST] */ + uint32 disable_period_base = 3; + + /* Disable counter maximum. * + * Range: [1:255] * + * Status: In use [06.20.00 - LATEST] */ + uint32 counter_max = 4; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_device_info.proto b/common/transport/coap/src/main/proto/efento/proto_device_info.proto index 3e1f066c59..f3908826ad 100644 --- a/common/transport/coap/src/main/proto/efento/proto_device_info.proto +++ b/common/transport/coap/src/main/proto/efento/proto_device_info.proto @@ -18,185 +18,330 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "DeviceInfoProtos"; +message ProtoRuntime +{ + + /* Up-time in seconds (since reset). * + * Status: In use [06.00 - LATEST] */ + uint32 up_time = 1; + + /* Message counters (since reset). * + * 1st item - Confirmable message attempts counter. * + * 2nd item - Non-confirmable message attempts counter. * + * 3rd item - Successful message counter. * + * Status: In use [06.00 - LATEST] */ + repeated uint32 message_counters = 2; + + /* MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 mcu_temperature = 3; + + /* Battery voltage in mV. * + * - [0:65534] - Battery voltage in millivolts * + * - 65535 - No measurement * + * Status: In use [07.00.00 - LATEST] / Previously as min_battery_voltage [06.00 - 06.xx.xx] */ + uint32 battery_voltage = 4; + + /* MCU temperature in Celsius when minimum battery voltage was reached. * + * Status: Deprecated [06.00 - 06.xx.xx] */ + sint32 min_battery_mcu_temperature = 5; + + /* Battery reset timestamp in seconds since 1st of January 1970 (epoch time). * + * Status: Deprecated [06.00 - 06.xx.xx] */ + uint32 battery_reset_timestamp = 6; + + /* Maximum MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 max_mcu_temperature = 7; + + /* Minimum MCU temperature in Celsius * + * Status: In use [06.00 - LATEST] */ + sint32 min_mcu_temperature = 8; + + /* Device's runtime errors. * + * Up to 20 error items supported. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 runtime_errors = 9; + + /* Number of sensor resets (since power-up). * + * Status: In use [06.21.00 - LATEST] */ + uint32 reset_counter = 10; +} + +message ProtoUpdateInfo +{ + + /* Timestamp of the last update check in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ + uint32 timestamp = 1; + + /* Status of the last update check: * + * - 1 - No update check * + * - 2 - No error * + * - 3 - UDP socekt error * + * - 4 - Invalid hash * + * - 5 - Missing packet * + * - 6 - Invalid data * + * - 7 - Sending timeout * + * - 8 - No software to update * + * - 9 - Sending unexpected error * + * - 10 - Unexpected error * + * Status: In use [06.00 - LATEST] */ + uint32 status = 2; +} + enum ModemType { - /* Invalid value */ + /* Invalid value. */ MODEM_TYPE_UNSPECIFIED = 0; - /* Quectel BC66 modem */ + /* Quectel BC66 modem. */ MODEM_TYPE_BC66 = 1; - /* Quectel BC66-NA modem */ + /* Quectel BC66-NA modem. */ MODEM_TYPE_BC66NA = 2; + + /* Uses a shared modem from another sensor. */ + MODEM_TYPE_SHARED_MODEM = 3; + + /* Quectel BC660 modem. */ + MODEM_TYPE_BC660 = 4; } -message ProtoRuntime +enum ModemFirmwareVersion { + /* Invalid value. */ + MODEM_FIRMWARE_VERSION_UNSPECIFIED = 0; - /* Up-time in seconds (since reset) */ - uint32 up_time = 1; + /* Unable to read firmware version from device. */ + MODEM_FIRMWARE_VERSION_READING_ERROR = 1; - /* Message counters (since reset). There are 3 counters: */ - /* message_counters[0] - Counter of confirmable messages attempts */ - /* message_counters[1] - Counter of non-confirmable messages attempts */ - /* message_counters[2] - Counter of succeeded messages */ - repeated uint32 message_counters = 2; + /* Unknown firmware version. */ + MODEM_FIRMWARE_VERSION_UNKNOWN = 2; - /* MCU temperature in Celsius */ - sint32 mcu_temperature = 3; + /* BC660KGLAAR01A05_01.002.01.002. */ + MODEM_FIRMWARE_VERSION_BC660_V1 = 3; - /* Minimum battery voltage in mV */ - uint32 min_battery_voltage = 4; + /* BC660KGLAAR01A05_01.200.01.200. */ + MODEM_FIRMWARE_VERSION_BC660_V2 = 4; - /* MCU temperature in Celsius, while the minimum battery voltage was reached */ - sint32 min_battery_mcu_temperature = 5; + /* BC660KGLAAR01A05_01.202.01.202. */ + MODEM_FIRMWARE_VERSION_BC660_V3 = 5; - /* Battery reset timestamp (Unix timestamp) */ - uint32 battery_reset_timestamp = 6; + /* BC660KGLAAR01A05_01.203.01.203. */ + MODEM_FIRMWARE_VERSION_BC660_V4 = 6; - /* Max MCU temperature in Celsius */ - sint32 max_mcu_temperature = 7; + /* BC660KGLAAR01A05_01.204.01.204. */ + MODEM_FIRMWARE_VERSION_BC660_V5 = 7; - /* Min MCU temperature in Celsius */ - sint32 min_mcu_temperature = 8; + /* BC660KGLAAR01A05_01.205.01.205. */ + MODEM_FIRMWARE_VERSION_BC660_V6 = 8; - /* Table of runtime errors. Max length: 20 */ - repeated uint32 runtime_errors = 9; + /* BC660KGLAAR01A05_01.301.01.301. */ + MODEM_FIRMWARE_VERSION_BC660_V7 = 9; + + /* BC660KGLAAR01A05_01.303.01.303. */ + MODEM_FIRMWARE_VERSION_BC660_V8 = 10; } message ProtoModem { + /* Modem type. * + * Status: In use [06.00 - LATEST] */ ModemType type = 1; - /* Parameters for BC66 modem: */ - /* parameters[0] - sc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[1] - sc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[2] - sc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[3] - sc_Cell id - Range: [1:268435456]. Unknown value: 0 */ - /* parameters[4] - sc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[5] - sc_RSRQ - [dB] - Range: [-20:-3]. Unknown value: 0 */ - /* parameters[6] - sc_RSSI - [dBm] - Range: [-110:-3] Unknown value: 0 */ - /* parameters[7] - sc_SINR - [dB] - Range: [-10:30]. Unknown value: 31 */ - /* parameters[8] - sc_Band - Range: [see module supported bands]. The current serving cell band. Unknown value: -1 */ - /* parameters[9] - sc_TAC - Range: [0:65536]. Unknown value: -1 */ - /* parameters[10] - sc_ECL - Range: [0:2]. Unknown value: -1 */ - /* parameters[11] - sc_TX_PWR - [0.1cBm] - Range [-440:230]. Unknown value: -1000 */ - /* parameters[12] - OP_MODE - Range: [0:3]. Unknown value: -1 */ - /* parameters[13] - nc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[14] - nc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[15] - nc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[16] - nc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[17] - RLC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[18] - RLC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[19] - MAC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[20] - MAC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[21] - MAC_UL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[22] - MAC_DL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[23] - MAC_UL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[24] - MAC_DL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[25] - MAC_UL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[26] - MAC_DL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[27] - RLC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[28] - RLC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[29] - MAC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[30] - MAC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[31] - sleep_duration - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[32] - Rx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[33] - Tx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ + /* Modem runtime parameters. * + * For BC66 modem (34 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:502]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [0.1dBm]. Range [-440:230]. Unknown value: -1000 * + * - 13th item - OP_MODE. Range: [0:3]. Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 16th item - nc_PCI. Range: [0:502]. Unknown value: -1 * + * - 17th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - RLC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 19th item - RLC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 20th item - MAC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 21th item - MAC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 22th item - MAC_UL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 23th item - MAC_DL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 24th item - MAC_UL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 25th item - MAC_DL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 26th item - MAC_UL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 27th item - MAC_DL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 28th item - RLC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 29th item - RLC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 30th item - MAC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 31th item - MAC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 32th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 33th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 34th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * For BC660 modem (22 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:21] mapped to * + * [Invalid, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, -0.5, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]. * + * Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:503]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [dBm]. Range [-45:23]. Unknown value: 128 * + * - 13th item - OP_MODE. Range: [0:3] mapped to [In-band same PCI, In-band different PCI, Guard band, Stand alone]. * + * Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_PCI. Range: [0:503]. Unknown value: -1 * + * - 16th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 17th item - nc_RSRQ [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 19th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 20th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 21st item - PLMN_state. Range: [0:3] mapped to [No PLMN, Searching, Selected, Unknown]. Unknown value: 3 * + * - 22nd item - select_PLMN. Range: [100:999999]. Unknown value: -1 * + * For shared modem client (4 parameters): * + * - 1st item - RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 2nd item - RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 3rd item - RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 4th item - SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 2; - /* ICCID of inserted/soldered sim card. String up to 22 characters long. */ - /* 0x7F if sim card is not detected, empty (not sent) if device does not have modem. */ - /* This field is only sent by device */ + /* Integrated Circuit Card Identifier (ICCID) of the inserted/soldered SIM card. * + * String up to 22 characters: * + * A string containing only the special character 0x7F (DEL) indicates that the SIM card is not detected. * + * Status: In use [06.07.04/06.09.09/06.10.10/06.11.09/06.12.06/06.13.00/06.21.00 - LATEST] */ string sim_card_identification = 3; + + /* Modem firmware version. * + * Status: In use [06.20.05/06.21.00 - LATEST] */ + ModemFirmwareVersion firmware_version = 4; + + /* Modem identification: * + * - IMEI (International Mobile Equipment Identity) of the modem * + * - Serial number of the most recently used modem-sharing sensor * + * String up to 15 characters. * + * Status: In use [07.00.00 - LATEST] */ + string modem_identification = 5; + + /* Modem statistics. * + * 1st item - Number of transmissions since the last device information message. Undefined value: 0. * + * 2nd item - Time in seconds since the last device information message. Undefined value: 0. * + * 3rd item - Total time in the power saving mode, in seconds. Undefined value: 0. * + * 4th item - Total time in the active state in seconds. Undefined value: 0. * + * Status: In use [07.00.00 - LATEST] */ + repeated uint32 modem_statistics = 6; } -message ProtoUpdateInfo +message ProtoDeviceInfo { - /* Timestamp of update (Unix timestamp) */ - uint32 timestamp = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* Update status, possible values: */ - /* - 1 - No update yet */ - /* - 2 - No error */ - /* - 3 - UDP socekt error */ - /* - 4 - Hash error */ - /* - 5 - Missing packet error */ - /* - 6 - Invalid data error */ - /* - 7 - Sending timeout error */ - /* - 8 - No SW to update error */ - /* - 9 - Sending unexpected error */ - /* - 10 - Unexpected error */ - uint32 status = 2; -} + reserved 2,4,5,6,7,8,9,10,11,12; -message ProtoDeviceInfo -{ + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ - /* Serial number of device */ - bytes serial_num = 1; + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; - /* Deprecated field */ - reserved 2; + /* DEVICE INFORMATION FIELDS ------------------------------------------------------------------------------------------------ */ - /* Software version e.g ver 06.10 -> 0x060A -> 1546 */ + /* Software version (excluding LTS). * + * Example: 1546 -> 0x060A -> SW version 06.10 * + * Status: In use [06.00 - LATEST] */ uint32 sw_version = 3; - /* Deprecated fields */ - reserved 4,5,6,7,8,9,10,11,12; + /* Software commit ID and LTS version. * + * String of 7 characters: * + * - Commit ID (examples: "fa02cd0" means the beginning of the commit ID "fa02cd0") [06.00 - 06.06.xx] * + * - LTS version and commit ID (examples: "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f") * + [06.07.00 - LATEST] * + * Status: In use [06.00 - LATEST] */ + string commit_id = 15; - /* Structure with battery and temperature information */ + /* Device runtime information. * + * Status: In use [06.00 - LATEST] */ ProtoRuntime runtime_info = 13; - /* Structure with modem specific runtime information */ + /* Memory statistics: * + * 1st item - Status of the non-volatile storage: * + * - 0 - Non-volatile storage works properly * + * - 1 - Non-volatile storage has some corrupt packets. Memory is read-only * + * - 2 - Non-volatile storage is corrupted. Memory is unavailable * + * 2nd item - Timestamp of the end of collecting statistics in seconds since 1st of January 1970 (epoch time). * + Undefined value: 4294967295. * + * 3rd item - Capacity of the memory in bytes. * + * 4th item - Used space in bytes. * + * 5th item - Size of invalid (outdated) packets in bytes. * + * 6th item - Size of corrupt packets in bytes. * + * 7th item - Number of valid packets. * + * 8th item - Number of invalid (outdated) packets. * + * 9th item - Number of corrupt packets. * + * 10th item - Number of all samples for channel 1 (valid packets). * + * 11th item - Number of all samples for channel 2 (valid packets). * + * 12th item - Number of all samples for channel 3 (valid packets). * + * 13th item - Number of all samples for channel 4 (valid packets). * + * 14th item - Number of all samples for channel 5 (valid packets). * + * 15th item - Number of all samples for channel 6 (valid packets). * + * 16th item - Timestamp of the first binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 17th item - Timestamp of the last binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 18th item - Timestamp of the last binary measurement marked as sent, in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 19th item - Timestamp of the first continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 20th item - Timestamp of the last continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 21th item - Timestamp of the last continuous measurement marked as sent, in seconds since 1st of January 1970 (epoch time).* + * Undefined value: 4294967295. * + * 22th item - NvM write counter. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 memory_statistics = 17; + + /* Last update check information. * + * Status: In use [06.08.00 - LATEST] */ + ProtoUpdateInfo last_update_info = 18; + + /* Modem information. * + * Only used if the sensor has a modem. * + * Status: In use [06.00 - LATEST] */ ProtoModem modem = 14; - /* String up to 7 bytes long. Software commit id e.g. "e0e8556" */ - /* From version 06.07 the first two characters indicate the LTS version. */ - /* For example: the value "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f" */ - string commit_id = 15; + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; - /* Memory statistics: */ - /* memory_statistics[0] - Status of Nv storage: */ - /* - 0 - Nv storage hasn't errors */ - /* - 1 - Nv storage has some corrupted packet. Memory is read-only */ - /* - 2 - Nv storage is corrupted. Memory is unavailable */ - /* memory_statistics[1] - Timestamp of the end of collecting statistics. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[2] - Capacity of memory in bytes */ - /* memory_statistics[3] - Used space in bytes */ - /* memory_statistics[4] - Size of invalid (outdated) packets in bytes */ - /* memory_statistics[5] - Size of corrupted packets in bytes */ - /* memory_statistics[6] - Number of valid packets */ - /* memory_statistics[7] - Number of invalid (outdated) packets */ - /* memory_statistics[8] - Number of corrupted packets */ - /* memory_statistics[9] - Number of all samples for channel 1 (valid packets) */ - /* memory_statistics[10] - Number of all samples for channel 2 (valid packets) */ - /* memory_statistics[11] - Number of all samples for channel 3 (valid packets) */ - /* memory_statistics[12] - Number of all samples for channel 4 (valid packets) */ - /* memory_statistics[13] - Number of all samples for channel 5 (valid packets) */ - /* memory_statistics[14] - Number of all samples for channel 6 (valid packets) */ - /* memory_statistics[15] - Timestamp of the first binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[16] - Timestamp of the last binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[17] - Timestamp of the last binary measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[18] - Timestamp of the first continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[19] - Timestamp of the last continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[20] - Timestamp of the last continuous measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[21] - NVM write counter */ - repeated uint32 memory_statistics = 17; - - /* Information about last sensor SW update */ - ProtoUpdateInfo last_update_info = 18; + /* Enclosure tamper alert: * + * - 0 - Inactive * + * - 1 - Active (enclosure opened) * + * - 2 - Inactive, was active (enclosure closed) * + * The transition from 'Inactive, was active' to 'Inactive' occurs after the alert has been successfully sent to the server * + * or the user has reset the alert via Bluetooth. * + * This field is only sent during server communication. * + * Status: In use [07.00.01 - LATEST] */ + uint32 enclosure_tamper_alert = 19; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto index fc72cc10e3..7e7e908e16 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto @@ -19,160 +19,201 @@ option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "MeasurementTypeProtos"; enum MeasurementType { - /* [] - No sensor on the channel */ + + /* [] - No sensor on the channel. */ MEASUREMENT_TYPE_NO_SENSOR = 0; - /* [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. Type: Continuous */ + /* [°C] - Celsius degree. Temperature. Resolution: 0.1°C. Range: [-273.2:4000.0]. Type: Continuous. */ MEASUREMENT_TYPE_TEMPERATURE = 1; - /* [% RH] - Relative humidity. Resolution 1%. Range [0:100]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity. Resolution: 1%. Range: [0:100]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY = 2; - /* [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. Atmospheric pressure. Type: Continuous */ + /* [hPa] - Hectopascal. Atmospheric pressure. Resolution: 0.1hPa. Range: [1.0:2000.0]. Type: Continuous. */ MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE = 3; - /* [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000]. Differential pressure. Type: Continuous */ + /* [Pa] - Pascal. Differential pressure. Resolution: 1Pa. Range: [-10000:10000]. Type: Continuous. */ MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE = 4; - /* Sign indicates state: (+) ALARM, (-) OK. Type: Binary */ + /* Sign indicates state: (+) Alarm, (-) OK. Type: Binary. */ MEASUREMENT_TYPE_OK_ALARM = 5; - /* [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. To get IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [IAQ] - IAQ index. Air quality. Resolution: 1IAQ. Range: [0:500]. Type: Continuous. * + * To obtain the IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_IAQ = 6; - /* Sign indicates water presence: (+) water not detected, (-) water detected. Type: Binary */ + /* Sign indicates state: (+) Water detected, (-) Water not detected. Type: Binary. */ MEASUREMENT_TYPE_FLOODING = 7; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. Type: Continuous */ + /* [NB] - Number of pulses. Number of pulses in a single period. Resolution: 1 pulse. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_PULSE_CNT = 8; - /* [Wh] - Watthour; Resolution 1Wh. Range [0:8000000]. Number of Watthours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Number of watt-hours in a single period. Resolution: 1Wh. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_ELECTRICITY_METER = 9; - /* [l] - Liter. Resolution 1l. Range [0:8000000]. Number of litres in a single period. Type: Continuous */ + /* [l] - Liter. Number of litres in a single period. Resolution: 1l. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_WATER_METER = 10; - /* [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). Type: Continuous */ + /* [kPa] - Kilopascal. Soil moisture (tension). Resolution: 1kPa. Range: [-1000:0]. Type: Continuous. */ MEASUREMENT_TYPE_SOIL_MOISTURE = 11; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon monoxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CO_GAS = 12; - /* [ppm] - Parts per million. Resolution 1.0ppm. Range [0:1000000]. Nitrogen dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Nitrogen dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NO2_GAS = 13; - /* [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Hydrogen sulfide concentration. Resolution: 0.01ppm. Range: [0.00:80000.00]. Type: Continuous. */ MEASUREMENT_TYPE_H2S_GAS = 14; - /* [lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. Type: Continuous */ + /* [lx] - Lux. Illuminance. Resolution: 0.1lx. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_AMBIENT_LIGHT = 15; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 1 micrometer. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 1 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_1_0 = 16; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 2.5 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 2.5 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_2_5 = 17; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 10 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 10 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_10_0 = 18; - /* [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. Type: Continuous */ + /* [dB] - Decibel. Noise level. Resolution: 0.1 dB. Range: [0.0:200.0]. Type: Continuous. */ MEASUREMENT_TYPE_NOISE_LEVEL = 19; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. Type: Continuous */ + /* [ppm] - Parts per million. Ammonia concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NH3_GAS = 20; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. Type: Continuous */ + /* [ppm] - Parts per million. Methane concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CH4_GAS = 21; - /* [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. Type: Continuous */ + /* [kPa] - Kilopascal (100kPa = 1bar). Pressure. Resolution: 1kPa. Range: [0:200000]. Type: Continuous. */ MEASUREMENT_TYPE_HIGH_PRESSURE = 22; - /* [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. Type: Continuous */ + /* [mm] - Millimeter. Distance. Resolution: 1mm. Range: [0:100000]. Type: Continuous. */ MEASUREMENT_TYPE_DISTANCE_MM = 23; - /* [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). Type: Continuous */ + /* [l] - Liter. Water meter (minor). Resolution: 1l. Range: [0:99]. Type: Continuous. * + * To obtain the liters, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_WATER_METER_ACC_MINOR = 24; - /* [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). Type: Continuous */ + /* [hl] - Hectoliter. Water meter (major). Resolution: 1hl. Range: [0:999999]. Type: Continuous. * + * To obtain the hectoliters, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR = 25; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the carbon dioxide concentration, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Auto calibration has not yet been performed * + * - 1 - The last auto calibration was successful * + * - 2 - Last auto calibration failed */ MEASUREMENT_TYPE_CO2_GAS = 26; - /* [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity (accurate). Resolution: 0.1%. Range: [0.0:100.0]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY_ACCURATE = 27; - /* [sIAQ] - Static IAQ index. Resolution 1IAQ. Range [0:10000]. To get static IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [sIAQ] - Static IAQ index. Air quality. Resolution: 1sIAQ. Range: [0:10000]. Type: Continuous. * + * To obtain the static IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_STATIC_IAQ = 28; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. CO2 equivalent. */ - /* To get CO2 equivalent the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. CO2 equivalent. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the CO2 equivalent, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_CO2_EQUIVALENT = 29; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ - /* To get breath VOC estimate the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. Breath VOC estimate. Resolution: 1ppm. Range: [0:100000]. Type: Continuous. * + * To obtain the breath VOC estimate, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_BREATH_VOC = 30; - /* Special measurement type reserved for cellular gateway. */ - /* Type: Continuous */ - MEASUREMENT_TYPE_CELLULAR_GATEWAY = 31; + /* Reserved for the Shared Modem functionality. Type: Continuous. */ + MEASUREMENT_TYPE_SHARED_MODEM = 31; - /* [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. Type: Continuous */ + /* [%] - Percentage. Generic type. Resolution: 0.01%. Range: [0.00:100.00]. Type: Continuous. */ MEASUREMENT_TYPE_PERCENTAGE = 32; - /* [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. Type: Continuous */ + /* [mV] - Millivolt. Voltage. Resolution: 0.1mV. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_VOLTAGE = 33; - /* [mA] - Milliampere. Resolution 0.01mA. Range [0.0:10000.00]. Type: Continuous */ + /* [mA] - Milliampere. Current. Resolution: 0.01mA. Range: [0.00:10000.00]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT = 34; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:1000000]. Type: Continuous */ + /* [NB] - Number of pulses. Pulse counter (minor). Resolution: 1 pulse. Range: [0:999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR = 35; - /* [kNB] Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. Type: Continuous */ + /* [kNB] - Number of kilopulses. Pulse counter (major). Resolution: 1 kilopulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of kilopulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR = 36; - /* [Wh] - Watt-hour; Resolution 1Wh. Range [0:1000000]. Number of watt-hours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Electricity meter (minor). Resolution: 1Wh. Range: [0:999]. Type: Continuous. * + * To obtain the watt-hours, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR = 37; - /* [kWh] - Kilowatt-hour; Resolution 1kWh. Range [0:1000000]. Number of kilowatt-hours in a single period. Type: Continuous */ + /* [kWh] - Kilowatt-hour. Electricity meter (major). Resolution: 1kWh. Range: [0:999999]. Type: Continuous. * + * To obtain the kilowatt-hours, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR = 38; - /* [NB] Number of pulses (wide range). Resolution 1 pulse. Range [0:999999]. Type: Continuous */ + /* [NB] - Number of pulses. Wide-range pulse counter (minor). Resolution: 1 pulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR = 39; - /* [MNB] Number of megapulses (wide range). Resolution 1 megapulse. Range [0:999999]. Type: Continuous */ + /* [MNB] - Number of megapulses. Wide-range pulse counter (major). Resolution: 1 megapulse. Range: [0:999999]. * + * Type: Continuous. * + * To obtain the number of megapulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR = 40; - /* [mA] - Milliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. Type: Continuous */ + /* [mA] - Milliampere. Current (precise). Resolution: 0.001mA. Range: [-4000.000:4000.000]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT_PRECISE = 41; - /* Sign indicates state: (+) ON, (-) OFF. Type: Binary */ + /* Sign indicates state: (+) ON, (-) OFF. Type: Binary. */ MEASUREMENT_TYPE_OUTPUT_CONTROL = 42; -} - + /* [Ω] - Ohm. Resolution: 1Ω. Range: [0:1000000]. Type: Continuous. */ + MEASUREMENT_TYPE_RESISTANCE = 43; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurements.proto b/common/transport/coap/src/main/proto/efento/proto_measurements.proto index 650487ff29..12626d4834 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurements.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurements.proto @@ -14,6 +14,7 @@ * limitations under the License. */ syntax = "proto3"; + import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; @@ -21,106 +22,166 @@ option java_outer_classname = "MeasurementsProtos"; message ProtoChannel { - /* Type of channel */ + /* Reserved fields. */ + reserved 6,7,8; + + /* Type of measurement. * + * Status: In use [06.00 - LATEST] */ MeasurementType type = 1; - /* Timestamp of the first sample (the oldest one) in seconds since UNIX EPOCH 01-01-1970 */ + /* Timestamp of the first (oldest) sample in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ int32 timestamp = 2; - /* Only used for 'Continuous' sensor types. Value used as the starting point for calculating the values of all */ - /* measurements in the package. */ - /* Format defined by 'MeasurementType' field */ + /* Start point of measurement values. * + * This is used as the base value for calculating the values of all channel measurements in the message. * + * Used for 'Continuous' sensor types only. * + * Format is defined by 'MeasurementType' field. * + * Status: In use [06.00 - LATEST] */ sint32 start_point = 4; - /* 'Continuous' sensor types */ - /* Value of the offset from the 'start_point' for each measurement in the package. The oldest sample first ([0]). */ - /* 'sample_offsets' format defined by 'MeasurementType' field. */ - /* If the 'sample_offset' has a value from the range [8355840: 8388607], it should be interpreted as a sensor error code. */ - /* In that case value of the 'start_point' field should not be added to this 'sample_offset'. See ES6-264 for error codes. */ - /* Example: MeasurementType = 1 (temperature), start_point = 100, sample_offsets[0] = 15, sample_offsets[1] = 20, */ - /* sample_offset[2] = 8388605 */ - /* 1st sample in the package temperature value = 11.5 °C, 2nd sample in the package temperature value = 12 °C */ - /* 3rd sample in the package has no temperature value. It has information about failure of MCP9808 (temperature) sensor. */ - /* Calculating timestamps of the measurements: timestamp = 1606391700, measurement_period_base = 60, */ - /* measurement_period_factor = 1. Timestamp of the 1st sample = 1606391700, timestamp of the 2nd sample = 1606391760, */ - /* timestamp of the 3rd sample 1606391820 */ - - /* 'Binary' sensor types: */ - /* Absolute value of the 'sample_offsets' field indicates the offset in seconds from 'timestamp' field. */ - /* Sign (- or +) indicates the state of measurements depending on the sensor type. */ - /* Value of this field equals to '1' or '-1' indicates the state at the 'timestamp'. Other values */ - /* indicate the state of the relay at the time (in seconds) equal to 'timestamp' + absolute value -1. */ - /* Values of this field are incremented starting from 1 (1->0: state at the time */ - /* of 'timestamp', 2->1: state at the time equal to 'timestamp' + 1 s, 3->2 : */ - /* state at the time equal to 'timestamp' + 2 s, etc.). The first and the last sample define the time range of the */ - /* measurements. Only state changes in the time range are included in the 'sample_offsets' field */ - /* Examples: if 'timestamp' value is 1553518060 and 'sample_offsets' equals '1', it means that at 1553518060 the state */ - /* was high, if 'timestamp' value is 1553518060 and 'sample_offsets' equals '-9', it means at 1553518068 the state was low */ + /* Measurement value offsets. * + * For 'Continuous' sensor types: * + * These are the values of the offsets from the 'start_point' for each channel measurement in the message. * + * Format is defined by 'MeasurementType' field. The first sample is the oldest. * + * If the sample offset has a value in the range [8355840:8388607], it should be interpreted as a sensor error code. * + * In this case, the value of the 'start_point' field should not be added to this sample offset. * + * Example: type = 1 (temperature) * + * start_point = 100 * + * sample_offsets[0] = 15, sample_offsets[1] = 20, sample_offsets[2] = 8388557 * + * timestamp = 1606391700 * + * measurement_period_base = 60 * + * measurement_period_factor = 1 * + * 1st measurement is the temperature value = 11.5°C, measured at 1606391700 * + * 2nd measurement is the temperature value = 12°C, measured at 1606391760 * + * 3rd measurement is the error code (failure of the SHT4x temperature sensor), measured at 1606391820 * + * For 'Binary' sensor types: * + * The absolute value of the sample offset minus 1 is the offset in seconds from the 'timestamp' field. * + * Sign (- or +) indicates the state of the measurement depending on the sensor type. * + * The first and the last samples define the time range of the measurements. * + * Only state changes within the time range are included in the 'sample_offsets' field. * + * Example: type = 5 (OK/Alarm) * + * sample_offsets[0] = 1, sample_offsets[1] = -9, sample_offsets[2] = 13 * + * timestamp = 1606391700 * + * 1st measurement is the state at 1606391700, which is Alarm (+) * + * 2nd measurement is the state at 1606391708, which is OK (-) * + * 3rd measurement is the state at 1606391712, which is Alarm (+) * + * Status: In use [06.00 - LATEST] */ repeated sint32 sample_offsets = 5 [packed=true]; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 lo_threshold = 6; */ - reserved 6; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 hi_threshold = 7; */ - reserved 7; - - /* Deprecated - configurations sent to endpoint 'c' */ - /* int32 diff_threshold = 8; */ - reserved 8; } message ProtoMeasurements { - /* Serial number of the device */ - bytes serial_num = 1; - - /* Battery status: true - battery ok, false - battery low */ - bool battery_status = 2; - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ + + reserved 10,11,12,13,14,15; + + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; + + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 9; + + /* Identifier of the current extended configuration. * + * The value of this field changes with every extended configuration change. * + * Status: In use [07.00.00 - LATEST] */ + uint32 extended_configuration_hash = 17; + + /* Battery level and status: * + * - [1:65525] - Battery voltage measurement and a battery status * + * - [65526:65531] - No battery voltage measurement and a battery status * + * To obtain the battery voltage, the battery_level value should be divided by 6 and the remainder of the division discarded. * + * The result is in hundredths of a volt or 10921 (no battery voltage measurement). * + * Battery level also includes a battery status as metadata (the remainder of the value divided by 6): * + * - 0 - Device is powered by a battery * + * - 1 - Device is powered by a rechargeable battery * + * - 2 - Device is powered by an external source and a battery is charging * + * - 3 - Reserved for the future use * + * - 4 - Reserved for the future use * + * - 5 - Reserved for the future use * + * Example: battery_level = 2474 * + * Battery voltage = 2474/6 = 412 = 4.12[V] * + * Battery status = 2474 % 6 = 2 * + * Status: In use [07.00.00 - LATEST] */ + uint32 battery_level = 18; + + /* MEASUREMENT FIELDS ------------------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 3; - /* Measurement period factor */ + /* Measurement period factor. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 8; + /* Measurements grouped by channel. * + * Status: In use [06.00 - LATEST] */ repeated ProtoChannel channels = 4; - /* Timestamp of the next scheduled transmission. If the device will not send data until this time, */ - /* it should be considered as 'lost' */ + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ + + /* Battery status: * + * - True - Battery OK * + * - False - Battery discharged * + * Status: In use [06.00 - LATEST] */ + bool battery_status = 2; + + /* Timestamp of the next scheduled transmission. * + * If the device has not sent any data by this time, it should be considered 'lost'. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 next_transmission_at = 5; - /* Reason of transmission - unsigned integer where each bit indicates different possible communication reason. */ - /* Can be more than one: */ - /* - bit 0: first message after sensor reset */ - /* - bit 1: user button triggered */ - /* - bit 2: user BLE triggered */ - /* - bit 3-7: number of retries -> incremented after each unsuccessful transmission. Max value 31. */ - /* Set to 0 after a successful transmission. */ - /* - bit 8...19: rule 1...12 was met */ - /* - bit 20: triggered after the end of the limit */ + /* Reason for transmission. * + * Bitmask, where each bit represents a specific reason for initiating communication (multiple reasons can be active * + * simultaneously): * + * - Bit 0 - First message after sensor reset * + * - Bit 1 - User-triggered (button press) * + * - Bit 2 - User-triggered (Bluetooth) * + * - Bit 3:7 - Number of retries - incremented after each failed transmission and reset upon success. Range: [0:31] * + * - Bit 8:19 - Rule-based triggers (12 bits): * + * - [06.00 - 07.01.xx]: Bitmask indicating which of the rules 1-12 were met * + * - [07.02.00 - LATEST]: The transfer bit 'i' (where i = 0 to 11) is set if either rule 'i' (0-11) is set * + * or if rule 'i + 12' (12-15) is set. This aggregation applies only to bits 0-3 (rules 0-3 and 12-15). * + * The mask aggregates 16 rules (0-15) onto 12 bits. * + * - Bit 20 - Triggered at the end of the defined limit * + * - Bit 21 - Triggered after a PIN tamper alert * + * - Bit 22 - Triggered after an enclosure tamper alert * + * - Bit 23 - Triggered before shutdown * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 transfer_reason = 6; - /* Signal strength level mapped from RSSI: */ - /* - 0: RSSI < -110 dBm */ - /* - 1: -110 dBm <= RSSI < -109 dBm */ - /* - 2...61: -109 <= RSSI < -108 dBm ... -50 dBm <= RSSI < -49 dBm */ - /* - 62: -49 dBm <= RSSI < -48 dBm */ - /* - 63: RSSI >= -48 dBm */ - /* - 99: Not known or not detectable */ + /* Signal strength (RSSI - Received Signal Strength Level): * + * - 0 - RSSI < -110dBm * + * - 1 - -110dBm <= RSSI < -109dBm * + * - 2:61 - -109dBm <= RSSI < -108dBm ... -50dBm <= RSSI < -49dBm * + * - 62 - -49dBm <= RSSI < -48dBm * + * - 63 - RSSI >= -48dBm * + * - 99 - Unknown or undetectable * + * This field is only sent during server communication. * + * Status: Deprecated [06.00 - 06.xx.xx] */ uint32 signal = 7; - /* Hash of the current configuration. Hash value changes each time a device receives a new configuration */ - uint32 hash = 9; - - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_rule.proto b/common/transport/coap/src/main/proto/efento/proto_rule.proto index 7473d9b52a..5d91712864 100644 --- a/common/transport/coap/src/main/proto/efento/proto_rule.proto +++ b/common/transport/coap/src/main/proto/efento/proto_rule.proto @@ -18,270 +18,287 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ProtoRuleProtos"; -/* Encoding A: used to set absolute values in the Rules (e.g. upper and lower threshold values) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [0:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000] */ -/* - OK/ALARM - Not applicable */ -/* - IAQ - [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [0:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [0:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [0:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.00:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [0:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [0:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Encoding R: used to set relative values in the Rules (e.g. differential threshold and hysteresis) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [0.1:4273.2]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [1:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [0.1:1999.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [1:20000] */ -/* - OK/ALARM - Not applicable */ -/* - VOC - [IAQ] - Iaq index. Resolution 1IAQ. Range [1:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [1:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [1:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [1:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [1:1000]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.01:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.1:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.1:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [1:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [1:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [1:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [1:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.1:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [1:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [1:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.01:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.1:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.01:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [1:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [1:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [1:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [0.001:8 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Condition to be checked by the device. If the condition is true, an action is triggered */ +/* Encoding A: used to set absolute values in rules (e.g., upper and lower thresholds): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [-273.2:4000.0] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [0:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [1.0:2000.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [-10000:10000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [0:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [0:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [0:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [-1000:0] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.00:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.0:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.0:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [0:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [0:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [0:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [0:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.0:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [0:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [0:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.00:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.0:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.00:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [0:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [0:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [0:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [-4000.000:4000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [0:1000000] */ + +/* Encoding R: used to set relative values in rules (e.g., differential threshold and hysteresis): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [0.1:4273.2] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [1:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [0.1:1999.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [1:20000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [1:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [1:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [1:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [1:1000] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.01:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.1:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.1:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [1:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [1:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [1:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [1:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.1:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [1:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [1:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.01:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.1:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.01:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [1:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [1:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [1:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [0.001:8000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [1:1000000] */ + +/* Condition to be checked by the device. If the condition is true, an action is triggered. */ enum Condition { - /* Invalid value */ + /* Invalid value. */ CONDITION_UNSPECIFIED = 0; - /* Threshold function for given rule_id is disabled */ + /* The rule is disabled. */ CONDITION_DISABLED = 1; - /* Upper threshold. Continuous sensors only. If the measurement (or average from a few measurements) is over the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample above threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Upper threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is over the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample above threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_HIGH_THRESHOLD = 2; - /* Lower threshold. Continuous sensors only. If the measurement (or average from a few measurements) is below the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample below threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Lower threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is below the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample below threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_LOW_THRESHOLD = 3; - /* Differential threshold. Continuous sensors only. If the absolute value of the difference between the last value sent to */ - /* the server and the measurement value (or average from a few measurements) is greater or equal to the value of */ - /* the threshold set, an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding R" format. Must match channel type */ - /* parameter[1] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[2] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[3] - Type of measurement (as described in MeasurementType). */ + /* Differential threshold. 'Continuous' sensors only. * + * If the absolute value of the difference between the last value sent to the server and the measurement value * + * (or average from a few measurements) is greater or equal to the value of the threshold set, an action is triggered. * + * parameter[0] - Threshold value in "Encoding R" format. Must match channel type. * + * parameter[1] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[2] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[3] - Type of measurement (as described in MeasurementType). */ CONDITION_DIFF_THRESHOLD = 4; - /* Change of binary sensor's state. Binary sensors only. Each change of the binary's sensor state will trigger an action. */ + /* Change of binary sensor's state. 'Binary' sensors only. * + * Each change of the binary sensor's state will trigger an action. */ CONDITION_BINARY_CHANGE_STATE = 5; - /* Logic operator. Used for combining multiple rules into more complex conditions. If the logic condition specified by */ - /* parameters (logic operator and selected rules) is met, an action is triggered. */ - /* parameter[0] - Logic operator (as described in LogicOperation). */ - /* parameter[1] - Rule selector (bit mask). Specifies which rules should be taken into account while determining */ - /* rules outcome. */ - /* parameter[2] - Rule negation (bit mask). Specifies which of chosen in parameter[1] rules should be negated */ - /* before determining rules outcome. */ - /* parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. */ - /* Range: [0:864000]. */ - /* parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. */ - /* Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ + /* Logic operator. Used for combining multiple rules into more complex conditions. * + * If the logic condition specified by parameters (logic operator and selected rules) is met, an action is triggered. * + * parameter[0] - Logic operator (as described in LogicOperator). * + * parameter[1] - Rule selector (bitmask). Specifies which rules should be taken into account while determining * + * rules outcome. * + * parameter[2] - Rule negation (bitmask). Specifies which of chosen in parameter[1] rules should be negated * + * before determining rules outcome. * + * parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. * + * Range: [0:864000]. * + * parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. * + * Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ CONDITION_LOGIC_OPERATOR = 6; - /* On measurement. Continous sensors only. The basic function is to trigger communication after measurement if at least 60s */ - /* have passed since the last one. Transmission may occur every x measurement. Optionally dependency on the other rule can */ - /* be configured, then, when all conditions are met, transmission is triggered. */ - /* parameter[0] - Send every n measurement. This parameter specifies every which measurement transmission will be triggered */ - /* if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will occur after */ - /* every measurement. */ - /* parameter[1] - Optional. Rule selector (bit mask). Specifies which rule should be taken into account while determining */ - /* the measurement rule outcome. */ - /* parameter[2] - Optional. Rule negation (bit mask). Specifies which of chosen in parameter[1] rule should be negated */ - /* before determining the measurement rule outcome. */ + /* On measurement. 'Continous' sensors only. + * The basic function is to trigger communication after measurement if at least 60s have passed since the last one. * + * Transmission may occur every n-th measurement. Optionally dependency on the other rule can be configured, then, when all * + * conditions are met, transmission is triggered. * + * parameter[0] - Send every n-th measurement. This parameter specifies every which measurement transmission will be * + * triggered if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will * + * occur after every measurement. * + * parameter[1] - Optional. Rule selector (bitmask). Specifies which rule should be taken into account while determining * + * the measurement rule outcome. * + * parameter[2] - Optional. Rule negation (bitmask). Specifies which of the rules selected in parameter[1] should be negated * + * before determining the measurement rule outcome. */ CONDITION_ON_MEASUREMENT = 7; + + /* On sensor error. 'Continous' sensors only. * + * If the sensor returns an error code, an action will be triggered. * + * The rule becomes inactive when a correct measurement appears. */ + CONDITION_ON_SENSOR_ERROR = 8; } /* Logic operators to be used for determining the outcome of rules with logic operator condition. */ enum LogicOperator { - /* Invalid use */ + /* Invalid value. */ LOGIC_OPERATOR_UNSPECIFIED = 0; - /* Logic AND */ + /* Logic AND. */ LOGIC_OPERATOR_AND = 1; - /* Logic OR */ + /* Logic OR. */ LOGIC_OPERATOR_OR = 2; } -/* Action to be triggered. Currently the only possible action is to trigger the transmission. */ -/* Other actions will be available in next SW releases. */ +/* Action to be triggered. */ enum Action { - /* Invalid value */ + /* Invalid value. */ ACTION_UNSPECIFIED = 0; - /* To trigger the transmission */ + /* To trigger the transmission. */ ACTION_TRIGGER_TRANSMISSION = 1; - /* To take no action. Possible for logic operator components */ + /* To take no action. Possible for logic operator components. */ ACTION_NO_ACTION = 2; - /* To trigger the transmission with ACK */ + /* To trigger the transmission with ACK. */ ACTION_TRIGGER_TRANSMISSION_WITH_ACK = 3; - /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). */ - /* Once the rule is deactived avertising period mode returns to previously configured value. */ + /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). * + * Once the rule is deactived avertising period mode returns to previously configured value. */ ACTION_FAST_ADVERTISING_MODE = 4; } /* Type of a rule calendars. */ enum CalendarType { - /* Invalid value */ + /* Invalid value. */ CALENDAR_TYPE_UNSPECIFIED = 0; - /* Type for inactive calendars */ + /* Calendar is inactive. */ CALENDAR_TYPE_DISABLED = 1; - /* Week type. Enables selcted rules on specified days of the week in specified time periods. */ - /* parameter[0] - Week day mask. Bitmask of days when selected rules are enabled */ - /* - Bit 0 - Sunday */ - /* - Bit 1 - Monday */ - /* ... */ - /* - Bit 6 - Saturday */ - /* parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). */ - /* parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). */ - /* Note: if 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' */ - /* and from 'From time' to 23:59. */ - /* parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets */ - /* - example - if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. */ + /* Week calendar. Enables selcted rules on specified days of the week in specified time periods. * + * parameter[0] - Week day mask. Bitmask of days when selected rules are enabled: * + * - Bit 0 - Sunday * + * - Bit 1 - Monday * + * ... * + * - Bit 6 - Saturday * + * parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). * + * parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). * + * parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets, * + * for example: if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. * + * Note: If 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' * + * and from 'From time' to 23:59. */ CALENDAR_TYPE_WEEK = 2; } /* Rules calendars. Used for enabling/disabling rules based on date/time. */ -/* It is possible to configure up to 6 calendars. Each of them can affect any number of rules. */ message ProtoCalendar { - /* Bit mask of selected rules. Mask on bits [0:11] */ - /* - Bit 0 - Rule ID 0 */ - /* - Bit 1 - Rule ID 1 */ - /* ... */ - /* - Bit 11 - Rule ID 11 */ + /* Selected rules. * + * Bitmask - up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]): * + * - Bit 0 - Rule 1 * + * - Bit 1 - Rule 2 * + * ... * + * - Bit 15 - Rule 16 * + * Status: In use [06.08.00 - LATEST] */ uint32 rule_mask = 1; - /* Calendars's parameters. Described in Type. */ + /* Calendar parameters (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ repeated sint32 parameters = 2; - /* Calendar's type. Described in Type. */ + /* Calendar type (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ CalendarType type = 3; } -/* Rules used to define edge logic on the device. Rules are defined by conditions and actions: */ -/* If Condition is true, trigger Action. It is possible to configure up to 12 rules and assign them to different channels. */ -/* One rule can be assigned to any number of channels. For instance rule "If temperature is over 10 C, trigger the transmission"*/ -/* can be assigned to channels 1 and 2. No matter to how many channels a rule is assigned, it's still counted as one rule. */ +/* Rules are used to define edge logic on the device. Rules are defined by conditions and actions. * + * If 'condition' is true, trigger 'action'. One rule can be assigned to any number of channels. * + * For instance rule "If temperature is over 10 C, trigger the transmission" can be assigned to channels 1 and 2. * + * No matter to how many channels a rule is assigned, it's still counted as one rule. */ message ProtoRule { - /* Channels to which the rule is assigned. One rule can be assigned to multiple channels as long as those are of the same type*/ - /* Bit mask on bits [0:5]. E.g. To assign the rule for channel 1: "000001", to assign rule to channels 2 and 4: "001010" */ + /* Channels to which the rule is assigned. * + * One rule can be assigned to multiple channels as long as those are of the same type. * + * Bitmask: * + * - Bit 0 - Channel 1 * + * - Bit 1 - Channel 2 * + * ... * + * - Bit 5 - Channel 6 * + * Status: In use [06.00 - LATEST] */ uint32 channel_mask = 1; - /* Rule's condition (as described in Condition). */ + /* Rule condition (as described in Condition). * + * Status: In use [06.00 - LATEST] */ Condition condition = 2; - /* Condition's parameters (as described in Condition). For binary sensors there are no parameters */ + /* Condition parameters (as described in Condition). * + * For 'Binary' sensors there are no parameters. * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 3; - /* Action to be triggered. */ + /* Action to be triggered. * + * Status: In use [06.00 - LATEST] */ Action action = 4; } \ No newline at end of file diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java index eb0a764f5e..c1d43e446e 100644 --- a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java @@ -16,20 +16,27 @@ package org.thingsboard.server.transport.coap.efento; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.gen.transport.coap.ConfigProtos; +import org.thingsboard.server.gen.transport.coap.ConfigTypesProtos; +import org.thingsboard.server.gen.transport.coap.DeviceInfoProtos; import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos.ProtoMeasurements; +import org.thingsboard.server.gen.transport.coap.ProtoRuleProtos; import org.thingsboard.server.transport.coap.CoapTransportContext; import org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils; import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Arrays; +import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -85,7 +92,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensorWithSomeMeasurements() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(5) @@ -93,7 +100,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) .setTimestamp(Math.toIntExact(tsInSec)) @@ -122,7 +129,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensor(MeasurementType measurementType, List sampleOffsets, String property, double expectedValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -130,7 +137,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -176,7 +183,7 @@ class CoapEfentoTransportResourceTest { String totalPropertyName, double expectedTotalValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -184,7 +191,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(Arrays.asList(MeasurementsProtos.ProtoChannel.newBuilder() .setType(majorType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -220,7 +227,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensor() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -228,7 +235,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MEASUREMENT_TYPE_OK_ALARM) .setTimestamp(Math.toIntExact(tsInSec)) @@ -247,7 +254,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensorWhenValueIsVarying(MeasurementType measurementType, String property, String expectedValueWhenOffsetNotOk, String expectedValueWhenOffsetOk) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -255,7 +262,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -282,7 +289,7 @@ class CoapEfentoTransportResourceTest { @Test void checkExceptionWhenChannelsListIsEmpty() { ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -290,7 +297,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .build(); UUID sessionId = UUID.randomUUID(); @@ -303,7 +310,7 @@ class CoapEfentoTransportResourceTest { void checkExceptionWhenValuesMapIsEmpty() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -311,7 +318,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MEASUREMENT_TYPE_TEMPERATURE) .setTimestamp(Math.toIntExact(tsInSec)) @@ -324,6 +331,589 @@ class CoapEfentoTransportResourceTest { .hasMessage("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); } + // ------------------------------------------------------------------------- + // ProtoDeviceInfo parsing tests + // ------------------------------------------------------------------------- + + @Test + void getEfentoDeviceInfo_parsesSwVersion() { + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setSwVersion(1546) // ver 06.10 => 0x060A => 1546 + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + + assertThat(result.getValues().getAsJsonObject().get("sw_version").getAsInt()).isEqualTo(1546); + } + + @Test + void getEfentoDeviceInfo_parsesAllMemoryStatistics() { + // proto uint32 maps to Java int; the "undefined" sentinel is 0xFFFFFFFF = -1 in signed int + int undefinedTs = -1; + int knownTs = 1_700_000_000; // arbitrary known Unix timestamp fitting in uint32 + // clearMemoryStatistics() is needed because minimalDeviceInfo() pre-populates 22 zeros; + // addAllMemoryStatistics() appends in proto, so without clear the test values land at indices 22-43 + DeviceInfoProtos.ProtoDeviceInfo.Builder builder = minimalDeviceInfo().clearMemoryStatistics(); + builder.addAllMemoryStatistics(List.of( + 0, // [0] nv_storage_status: 0 = no errors + knownTs, // [1] timestamp_of_the_end_of_collecting_statistics + 1048576, // [2] capacity_of_memory_in_bytes + 512000, // [3] used_space_in_bytes + 1024, // [4] size_of_invalid_packets_in_bytes + 256, // [5] size_of_corrupted_packets_in_bytes + 100, // [6] number_of_valid_packets + 10, // [7] number_of_invalid_packets + 2, // [8] number_of_corrupted_packets + 500, // [9] number_of_all_samples_for_channel_1 + 400, // [10] number_of_all_samples_for_channel_2 + 300, // [11] number_of_all_samples_for_channel_3 + 200, // [12] number_of_all_samples_for_channel_4 + 100, // [13] number_of_all_samples_for_channel_5 + 50, // [14] number_of_all_samples_for_channel_6 + undefinedTs, // [15] timestamp_of_the_first_binary_measurement (undefined) + undefinedTs, // [16] timestamp_of_the_last_binary_measurement (undefined) + undefinedTs, // [17] timestamp_of_the_first_binary_measurement_sent (undefined) + knownTs, // [18] timestamp_of_the_first_continuous_measurement + knownTs, // [19] timestamp_of_the_last_continuous_measurement + knownTs, // [20] timestamp_of_the_last_continuous_measurement_sent + 42 // [21] nvm_write_counter + )); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(builder.build()); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("nv_storage_status").getAsLong()).isEqualTo(0); + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("capacity_of_memory_in_bytes").getAsLong()).isEqualTo(1048576L); + assertThat(json.get("used_space_in_bytes").getAsLong()).isEqualTo(512000L); + assertThat(json.get("size_of_invalid_packets_in_bytes").getAsLong()).isEqualTo(1024L); + assertThat(json.get("size_of_corrupted_packets_in_bytes").getAsLong()).isEqualTo(256L); + assertThat(json.get("number_of_valid_packets").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_invalid_packets").getAsLong()).isEqualTo(10L); + assertThat(json.get("number_of_corrupted_packets").getAsLong()).isEqualTo(2L); + assertThat(json.get("number_of_all_samples_for_channel_1").getAsLong()).isEqualTo(500L); + assertThat(json.get("number_of_all_samples_for_channel_2").getAsLong()).isEqualTo(400L); + assertThat(json.get("number_of_all_samples_for_channel_3").getAsLong()).isEqualTo(300L); + assertThat(json.get("number_of_all_samples_for_channel_4").getAsLong()).isEqualTo(200L); + assertThat(json.get("number_of_all_samples_for_channel_5").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_all_samples_for_channel_6").getAsLong()).isEqualTo(50L); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("nvm_write_counter").getAsLong()).isEqualTo(42L); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfo() { + // 34 modem parameters (indices 0-33) as defined in proto_device_info.proto + List params = List.of( + 0, // [0] sc_EARNFCN_offset + 1000, // [1] sc_EARFCN + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 1, // [14] nc_EARNFCN_offset + 100, // [15] nc_PCI + -95, // [16] nc_RSRP + 5, // [17] RLC_UL_BLER + 3, // [18] RLC_DL_BLER + 4, // [19] MAC_UL_BLER + 2, // [20] MAC_DL_BLER + 50000,// [21] MAC_UL_TOTAL_BYTES + 60000,// [22] MAC_DL_TOTAL_BYTES + 200, // [23] MAC_UL_total_HARQ_Tx + 150, // [24] MAC_DL_total_HARQ_Tx + 10, // [25] MAC_UL_HARQ_re_Tx + 8, // [26] MAC_DL_HARQ_re_Tx + 1000, // [27] RLC_UL_tput + 1200, // [28] RLC_DL_tput + 900, // [29] MAC_UL_tput + 1100, // [30] MAC_DL_tput + 5000, // [31] sleep_duration + 300, // [32] rx_time + 100 // [33] tx_time + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(params) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC66"); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(0); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_EARNFCN_offset").getAsInt()).isEqualTo(1); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("RLC_UL_BLER").getAsInt()).isEqualTo(5); + assertThat(json.get("RLC_DL_BLER").getAsInt()).isEqualTo(3); + assertThat(json.get("MAC_UL_BLER").getAsInt()).isEqualTo(4); + assertThat(json.get("MAC_DL_BLER").getAsInt()).isEqualTo(2); + assertThat(json.get("MAC_UL_TOTAL_BYTES").getAsInt()).isEqualTo(50000); + assertThat(json.get("MAC_DL_TOTAL_BYTES").getAsInt()).isEqualTo(60000); + assertThat(json.get("MAC_UL_total_HARQ_Tx").getAsInt()).isEqualTo(200); + assertThat(json.get("MAC_DL_total_HARQ_Tx").getAsInt()).isEqualTo(150); + assertThat(json.get("MAC_UL_HARQ_re_Tx").getAsInt()).isEqualTo(10); + assertThat(json.get("MAC_DL_HARQ_re_Tx").getAsInt()).isEqualTo(8); + assertThat(json.get("RLC_UL_tput").getAsInt()).isEqualTo(1000); + assertThat(json.get("RLC_DL_tput").getAsInt()).isEqualTo(1200); + assertThat(json.get("MAC_UL_tput").getAsInt()).isEqualTo(900); + assertThat(json.get("MAC_DL_tput").getAsInt()).isEqualTo(1100); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoBC660() { + // 22 modem parameters for MODEM_TYPE_BC660 as defined in proto_device_info.proto + List params = List.of( + 1000, // [0] sc_EARFCN + 5, // [1] sc_EARNFCN_offset + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 100, // [14] nc_PCI + -95, // [15] nc_RSRP + -12, // [16] nc_RSRQ + 5000, // [17] sleep_duration + 300, // [18] rx_time + 100, // [19] tx_time + 1, // [20] PLMN_state + 26201 // [21] select_PLMN + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC660) + .addAllParameters(params) + .setSimCardIdentification("89001012012341234120") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_BC660_V2) + .setModemIdentification("123456789012345") + .addAllModemStatistics(List.of(10, 3600, 7200, 600)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC660"); + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89001012012341234120"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_BC660_V2"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("123456789012345"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(10); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(7200); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(600); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(5); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("nc_RSRQ").getAsInt()).isEqualTo(-12); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + assertThat(json.get("PLMN_state").getAsInt()).isEqualTo(1); + assertThat(json.get("select_PLMN").getAsInt()).isEqualTo(26201); + // BC66-specific fields must not be present + assertThat(json.has("nc_EARNFCN_offset")).isFalse(); + assertThat(json.has("RLC_UL_BLER")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoSharedModem() { + // 4 modem parameters for MODEM_TYPE_SHARED_MODEM as defined in proto_device_info.proto + List params = List.of( + -90, // [0] RSRP + -10, // [1] RSRQ + -80, // [2] RSSI + 15 // [3] SINR + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_SHARED_MODEM) + .addAllParameters(params) + .setModemIdentification("SN-ABCDEF") + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_SHARED_MODEM"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("SN-ABCDEF"); + assertThat(json.get("RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("SINR").getAsInt()).isEqualTo(15); + // BC66/BC660-specific fields must not be present + assertThat(json.has("sc_EARFCN")).isFalse(); + assertThat(json.has("sc_EARNFCN_offset")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesNewModemFields() { + // New ProtoModem fields: sim_card_identification, firmware_version, modem_identification, modem_statistics + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(java.util.Collections.nCopies(34, 0)) + .setSimCardIdentification("89012345678901234567") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_READING_ERROR) + .setModemIdentification("352519100417272") + .addAllModemStatistics(List.of(5, 1800, 3600, 120)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89012345678901234567"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_READING_ERROR"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("352519100417272"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(5); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(1800); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(120); + } + + @Test + void getEfentoDeviceInfo_parsesRuntimeInfo() { + long batteryResetTs = 1_700_000_000L; + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(3600) + .addAllMessageCounters(List.of(10, 5, 9)) + .setMcuTemperature(25) + .setBatteryVoltage(3200) + .setMinBatteryMcuTemperature(20) + .setBatteryResetTimestamp((int) batteryResetTs) + .setMaxMcuTemperature(40) + .setMinMcuTemperature(10) + .addAllRuntimeErrors(List.of(0, 1)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("up_time").getAsLong()).isEqualTo(3600); + assertThat(json.get("mcu_temp").getAsInt()).isEqualTo(25); + assertThat(json.get("min_battery_voltage").getAsLong()).isEqualTo(3200); + assertThat(json.get("min_battery_mcu_temp").getAsInt()).isEqualTo(20); + assertThat(json.get("battery_reset_timestamp").getAsString()).isEqualTo(formatDate(batteryResetTs)); + assertThat(json.get("max_mcu_temp").getAsInt()).isEqualTo(40); + assertThat(json.get("min_mcu_temp").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_confirmable_messages_attempts").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_non_confirmable_messages_attempts").getAsInt()).isEqualTo(5); + assertThat(json.get("counter_of_succeeded_messages").getAsInt()).isEqualTo(9); + assertThat(json.get("runtime_errors").getAsInt()).isEqualTo(2); // count of errors, not values + } + + @Test + void getEfentoDeviceInfo_undefinedTimestampRenderedAsUndefinedString() { + // -1 as signed int == 0xFFFFFFFF == uint32 max (4294967295), the "Undefined" sentinel + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo().clearMemoryStatistics() + .addAllMemoryStatistics(List.of( + 0, -1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + -1, -1, -1, + -1, -1, -1, + 0)) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo("Undefined"); + } + + @Test + void getEfentoDeviceInfo_tsIsCurrentTimeMillis() { + long before = System.currentTimeMillis(); + CoapEfentoTransportResource.EfentoTelemetry result = + coapEfentoTransportResource.getEfentoDeviceInfo(minimalDeviceInfo().build()); + long after = System.currentTimeMillis(); + + assertThat(result.getTs()).isBetween(before, after); + } + + // ------------------------------------------------------------------------- + // ProtoConfig / getEfentoConfiguration parsing tests + // ------------------------------------------------------------------------- + + @Test + void getEfentoConfiguration_parsesServerCommunicationFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataServerIp("18.184.24.239") + .setDataServerPort(5683) + .setUpdateServerIp("efento.update.io") + .setUpdateServerPortCoap(5684) + .setUpdateServerPortUdp(5685) + .setTransmissionInterval(300) + .setAckInterval(600) + .setSupervisionPeriod(3600) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataServerIp").getAsString()).isEqualTo("18.184.24.239"); + assertThat(json.get("dataServerPort").getAsLong()).isEqualTo(5683); + assertThat(json.get("updateServerIp").getAsString()).isEqualTo("efento.update.io"); + assertThat(json.get("updateServerPortCoap").getAsLong()).isEqualTo(5684); + assertThat(json.get("updateServerPortUdp").getAsLong()).isEqualTo(5685); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(300); + assertThat(json.get("ackInterval").getAsLong()).isEqualTo(600); + assertThat(json.get("supervisionPeriod").getAsLong()).isEqualTo(3600); + } + + @Test + void getEfentoConfiguration_parsesMeasurementPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setMeasurementPeriodBase(60) + .setMeasurementPeriodFactor(5) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(60); + assertThat(json.get("measurementPeriodFactor").getAsLong()).isEqualTo(5); + } + + @Test + void getEfentoConfiguration_parsesBooleanRequestFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDeviceInfoRequest(true) + .setUpdateSoftwareRequest(true) + .setConfigurationRequest(true) + .setAcceptWithoutTestingRequest(true) + .setResetMemoryRequest(true) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isTrue(); + assertThat(json.get("updateSoftwareRequest").getAsBoolean()).isTrue(); + assertThat(json.get("configurationRequest").getAsBoolean()).isTrue(); + assertThat(json.get("acceptWithoutTestingRequest").getAsBoolean()).isTrue(); + assertThat(json.get("resetMemoryRequest").getAsBoolean()).isTrue(); + } + + @Test + void getEfentoConfiguration_parsesModemAndNetworkFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setApn("internet.example.com") + .setApnUsername("user") + .setApnPassword("pass") + .setPlmnSelection(26001) + .setModemBandsMask(2084) // bands 3, 8, 20 + .setNetworkTroubleshooting(2) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("apn").getAsString()).isEqualTo("internet.example.com"); + assertThat(json.get("apnUsername").getAsString()).isEqualTo("user"); + assertThat(json.get("apnPassword").getAsString()).isEqualTo("pass"); + assertThat(json.get("plmnSelection").getAsLong()).isEqualTo(26001); + assertThat(json.get("modemBandsMask").getAsLong()).isEqualTo(2084); + assertThat(json.get("networkTroubleshooting").getAsLong()).isEqualTo(2); + } + + @Test + void getEfentoConfiguration_parsesCloudTokenFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setCloudToken("my-device-token") + .setCloudTokenConfig(1) + .setCloudTokenCoapOption(65000) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("cloudToken").getAsString()).isEqualTo("my-device-token"); + assertThat(json.get("cloudTokenConfig").getAsLong()).isEqualTo(1); + assertThat(json.get("cloudTokenCoapOption").getAsLong()).isEqualTo(65000); + } + + @Test + void getEfentoConfiguration_parsesEndpointFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataEndpoint("/m") + .setConfigurationEndpoint("/c") + .setDeviceInfoEndpoint("/i") + .setTimeEndpoint("/t") + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataEndpoint").getAsString()).isEqualTo("/m"); + assertThat(json.get("configurationEndpoint").getAsString()).isEqualTo("/c"); + assertThat(json.get("deviceInfoEndpoint").getAsString()).isEqualTo("/i"); + assertThat(json.get("timeEndpoint").getAsString()).isEqualTo("/t"); + } + + @Test + void getEfentoConfiguration_parsesBleAdvertisingPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setBleAdvertisingPeriod(ConfigTypesProtos.ProtoBleAdvertisingPeriod.newBuilder() + .setMode(ConfigTypesProtos.BleAdvertisingPeriodMode.BLE_ADVERTISING_PERIOD_MODE_NORMAL) + .setNormal(1600) + .setFast(320) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var ble = json.get("bleAdvertisingPeriod").getAsJsonObject(); + + assertThat(ble.get("mode").getAsString()).isEqualTo("BLE_ADVERTISING_PERIOD_MODE_NORMAL"); + assertThat(ble.get("normal").getAsLong()).isEqualTo(1600); + assertThat(ble.get("fast").getAsLong()).isEqualTo(320); + } + + @Test + void getEfentoConfiguration_parsesRepeatedChannelTypes() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_HUMIDITY) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var channelTypes = json.get("channelTypes").getAsJsonArray(); + + assertThat(channelTypes).hasSize(3); + assertThat(channelTypes.get(0).getAsString()).isEqualTo("MEASUREMENT_TYPE_TEMPERATURE"); + assertThat(channelTypes.get(1).getAsString()).isEqualTo("MEASUREMENT_TYPE_HUMIDITY"); + assertThat(channelTypes.get(2).getAsString()).isEqualTo("MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE"); + } + + @Test + void getEfentoConfiguration_parsesEdgeLogicRules() throws InvalidProtocolBufferException { + // ProtoRule uses channel_mask (bit mask), condition, parameters, action + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addRules(ProtoRuleProtos.ProtoRule.newBuilder() + .setChannelMask(1) // channel 1 → bit mask 0b000001 + .setCondition(ProtoRuleProtos.Condition.CONDITION_HIGH_THRESHOLD) + .addParameters(500) // threshold value + .setAction(ProtoRuleProtos.Action.ACTION_TRIGGER_TRANSMISSION) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var rules = json.get("rules").getAsJsonArray(); + + assertThat(rules).hasSize(1); + var rule = rules.get(0).getAsJsonObject(); + assertThat(rule.get("channelMask").getAsInt()).isEqualTo(1); + assertThat(rule.get("condition").getAsString()).isEqualTo("CONDITION_HIGH_THRESHOLD"); + assertThat(rule.get("parameters").getAsJsonArray().get(0).getAsInt()).isEqualTo(500); + assertThat(rule.get("action").getAsString()).isEqualTo("ACTION_TRIGGER_TRANSMISSION"); + } + + @Test + void getEfentoConfiguration_emptyConfigProducesJsonWithDefaultValues() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder().build().toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + // JsonFormat with includingDefaultValueFields prints all fields — verify a representative subset + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(0); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(0); + assertThat(json.get("dataServerIp").getAsString()).isEmpty(); + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isFalse(); + assertThat(json.get("cloudToken").getAsString()).isEmpty(); + } + + /** + * Builds a ProtoDeviceInfo with the minimum set of repeated fields required by getEfentoDeviceInfo: + * 22 memory_statistics, 34 modem parameters and 3 message_counters — all set to 0. + */ + private static DeviceInfoProtos.ProtoDeviceInfo.Builder minimalDeviceInfo() { + List zeroMemStats = java.util.Collections.nCopies(22, 0); + List zeroModemParams = java.util.Collections.nCopies(34, 0); + return DeviceInfoProtos.ProtoDeviceInfo.newBuilder() + .setSerialNumber(integerToByteString(1234)) + .setCloudToken("test_token") + .setSwVersion(0) + .addAllMemoryStatistics(zeroMemStats) + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_UNSPECIFIED) + .addAllParameters(zeroModemParams) + .build()) + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(0) + .addAllMessageCounters(List.of(0, 0, 0)) + .build()); + } + + private static String formatDate(long seconds) { + SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z"); + return sdf.format(new Date(TimeUnit.SECONDS.toMillis(seconds))); + } + public static ByteString integerToByteString(Integer intValue) { // Allocate a ByteBuffer with the size of an integer (4 bytes) ByteBuffer buffer = ByteBuffer.allocate(Integer.BYTES); @@ -344,7 +934,7 @@ class CoapEfentoTransportResourceTest { boolean isBinarySensor) { for (int i = 0; i < actualEfentoMeasurements.size(); i++) { CoapEfentoTransportResource.EfentoTelemetry actualEfentoMeasurement = actualEfentoMeasurements.get(i); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNum().toByteArray())); + assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNumber().toByteArray())); assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); MeasurementsProtos.ProtoChannel protoChannel = incomingMeasurements.getChannelsList().get(0); long measuredAt = isBinarySensor ? From 2cf1a76f03f6f58260cb4b638db1b131200b782d Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 31 Mar 2026 16:36:13 +0300 Subject: [PATCH 026/123] efento data point optimization: general parameters are saved once with the timestamp of the first sample of the first channel --- .../efento/CoapEfentoTransportResource.java | 17 +++-- .../coap/efento/utils/CoapEfentoUtils.java | 4 +- .../CoapEfentoTransportResourceTest.java | 63 ++++++------------- 3 files changed, 32 insertions(+), 52 deletions(-) diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index 3a434b963b..dd23a20213 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -53,6 +53,7 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -258,6 +259,12 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } Map valuesMap = new TreeMap<>(); + // general measurements per message + Optional channel1 = protoMeasurements.getChannelsList().stream() + .findFirst(); + long startTs = channel1.map(value -> TimeUnit.SECONDS.toMillis(value.getTimestamp())).orElseGet(System::currentTimeMillis); + valuesMap.put(startTs, CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, nextTransmissionAtMillis, signal)); + for (int channel = 0; channel < channelsList.size(); channel++) { ProtoChannel protoChannel = channelsList.get(channel); List sampleOffsetsList = protoChannel.getSampleOffsetsList(); @@ -271,6 +278,10 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { long measurementPeriodMillis = TimeUnit.SECONDS.toMillis(measurementPeriod); long startTimestampMillis = TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()); + // measurements per channel + JsonObject tsValues = valuesMap.computeIfAbsent(startTimestampMillis, k -> new JsonObject()); + tsValues.addProperty("measurement_interval", measurementPeriod); + for (int i = 0; i < sampleOffsetsList.size(); i++) { int sampleOffset = sampleOffsetsList.get(i); if (isSensorError(sampleOffset)) { @@ -290,13 +301,11 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } long sampleOffsetMillis = TimeUnit.SECONDS.toMillis(sampleOffset); long measurementTimestamp = startTimestampMillis + Math.abs(sampleOffsetMillis); - values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> new JsonObject()); addBinarySample(protoChannel, currentIsOk, values, channel + 1, sessionId); } else { long timestampMillis = startTimestampMillis + i * measurementPeriodMillis; - values = valuesMap.computeIfAbsent(timestampMillis, k -> CoapEfentoUtils.setDefaultMeasurements( - serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(timestampMillis, k -> new JsonObject()); addContinuesSample(protoChannel, sampleOffset, values, channel + 1, sessionId); } } diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java index 015bf07292..1acac0ec75 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java @@ -59,14 +59,12 @@ public class CoapEfentoUtils { return String.format("%s UTC", simpleDateFormat.format(new Date(timestampInMillis))); } - public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long measurementPeriod, long nextTransmissionAtMillis, long signal, long startTimestampMillis) { + public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long nextTransmissionAtMillis, long signal) { JsonObject values = new JsonObject(); values.addProperty("serial", serialNumber); values.addProperty("battery", batteryStatus ? "ok" : "low"); - values.addProperty("measured_at", convertTimestampToUtcString(startTimestampMillis)); values.addProperty("next_transmission_at", convertTimestampToUtcString(nextTransmissionAtMillis)); values.addProperty("signal", signal); - values.addProperty("measurement_interval", measurementPeriod); return values; } diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java index c1d43e446e..5f9cee38a9 100644 --- a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java @@ -36,10 +36,12 @@ import java.nio.ByteBuffer; import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Arrays; +import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -121,7 +123,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 180 * 5) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("temperature_1").getAsDouble()).isEqualTo(22.4); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("humidity_2").getAsDouble()).isEqualTo(30); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5); } @ParameterizedTest @@ -149,7 +151,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsDouble()).isEqualTo(expectedValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkContinuousSensor() { @@ -207,7 +209,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(totalPropertyName + "_2").getAsDouble()).isEqualTo(expectedTotalValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkPulseCounterSensors() { @@ -246,7 +248,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("ok_alarm_1").getAsString()).isEqualTo("ALARM"); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14); } @ParameterizedTest @@ -275,7 +277,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetNotOk); assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 9) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetOk); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkBinarySensorWhenValueIsVarying() { @@ -306,31 +308,6 @@ class CoapEfentoTransportResourceTest { .hasMessage("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); } - @Test - void checkExceptionWhenValuesMapIsEmpty() { - long tsInSec = Instant.now().getEpochSecond(); - ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNumber(integerToByteString(1234)) - .setCloudToken("test_token") - .setMeasurementPeriodBase(180) - .setMeasurementPeriodFactor(1) - .setBatteryStatus(true) - .setSignal(0) - .setNextTransmissionAt(1000) - .setTransferReason(0) - .setConfigurationHash(0) - .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() - .setType(MEASUREMENT_TYPE_TEMPERATURE) - .setTimestamp(Math.toIntExact(tsInSec)) - .build()) - .build(); - UUID sessionId = UUID.randomUUID(); - - assertThatThrownBy(() -> coapEfentoTransportResource.getEfentoMeasurements(measurements, sessionId)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); - } - // ------------------------------------------------------------------------- // ProtoDeviceInfo parsing tests // ------------------------------------------------------------------------- @@ -930,21 +907,17 @@ class CoapEfentoTransportResourceTest { private void checkDefaultMeasurements(ProtoMeasurements incomingMeasurements, List actualEfentoMeasurements, - long expectedMeasurementInterval, - boolean isBinarySensor) { - for (int i = 0; i < actualEfentoMeasurements.size(); i++) { - CoapEfentoTransportResource.EfentoTelemetry actualEfentoMeasurement = actualEfentoMeasurements.get(i); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNumber().toByteArray())); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); - MeasurementsProtos.ProtoChannel protoChannel = incomingMeasurements.getChannelsList().get(0); - long measuredAt = isBinarySensor ? - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()) + Math.abs(TimeUnit.SECONDS.toMillis(protoChannel.getSampleOffsetsList().get(i))) - 1000 : - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp() + i * expectedMeasurementInterval); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measured_at").getAsString()).isEqualTo(convertTimestampToUtcString(measuredAt)); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); - } + long expectedMeasurementInterval) { + CoapEfentoTransportResource.EfentoTelemetry efentoTelemetry = actualEfentoMeasurements.stream() + .sorted(Comparator.comparing(CoapEfentoTransportResource.EfentoTelemetry::getTs)) + .toList().get(0); + + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNumber().toByteArray())); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); + } } From c84810aa1ed2104c16e114d87cc3df36be6bbf36 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 31 Mar 2026 16:46:32 +0300 Subject: [PATCH 027/123] refactoring --- .../coap/efento/CoapEfentoTransportResource.java | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index dd23a20213..fb3abca4fb 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -260,10 +260,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { Map valuesMap = new TreeMap<>(); // general measurements per message - Optional channel1 = protoMeasurements.getChannelsList().stream() - .findFirst(); - long startTs = channel1.map(value -> TimeUnit.SECONDS.toMillis(value.getTimestamp())).orElseGet(System::currentTimeMillis); - valuesMap.put(startTs, CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, nextTransmissionAtMillis, signal)); + valuesMap.put(TimeUnit.SECONDS.toMillis(channelsList.get(0).getTimestamp()), CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, nextTransmissionAtMillis, signal)); for (int channel = 0; channel < channelsList.size(); channel++) { ProtoChannel protoChannel = channelsList.get(channel); @@ -280,7 +277,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { // measurements per channel JsonObject tsValues = valuesMap.computeIfAbsent(startTimestampMillis, k -> new JsonObject()); - tsValues.addProperty("measurement_interval", measurementPeriod); + tsValues.addProperty("measurement_interval_" + channel, measurementPeriod); for (int i = 0; i < sampleOffsetsList.size(); i++) { int sampleOffset = sampleOffsetsList.get(i); @@ -311,10 +308,6 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } } - if (CollectionUtils.isEmpty(valuesMap)) { - throw new IllegalStateException("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); - } - return valuesMap.entrySet().stream() .map(entry -> new EfentoTelemetry(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); From 29e82dbec286e7263a8086bee9a6005f6dd49c45 Mon Sep 17 00:00:00 2001 From: dashevchenko Date: Tue, 31 Mar 2026 16:52:16 +0300 Subject: [PATCH 028/123] refactoring --- .../transport/coap/efento/CoapEfentoTransportResource.java | 3 +-- .../coap/efento/CoapEfentoTransportResourceTest.java | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index fb3abca4fb..843f857218 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -53,7 +53,6 @@ import java.text.SimpleDateFormat; import java.util.Date; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -277,7 +276,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { // measurements per channel JsonObject tsValues = valuesMap.computeIfAbsent(startTimestampMillis, k -> new JsonObject()); - tsValues.addProperty("measurement_interval_" + channel, measurementPeriod); + tsValues.addProperty("measurement_interval_" + (channel + 1), measurementPeriod); for (int i = 0; i < sampleOffsetsList.size(); i++) { int sampleOffset = sampleOffsetsList.get(i); diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java index 5f9cee38a9..f6eaa9976f 100644 --- a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java @@ -916,8 +916,9 @@ class CoapEfentoTransportResourceTest { assertThat(efentoTelemetry.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); assertThat(efentoTelemetry.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); assertThat(efentoTelemetry.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); - assertThat(efentoTelemetry.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); - + for (int i = 1; i < incomingMeasurements.getChannelsCount() + 1; i++) { + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("measurement_interval_" + i).getAsDouble()).isEqualTo(expectedMeasurementInterval); + } } } From 4c88676321839442613fa3a425df8933a9578497 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 2 Apr 2026 09:35:00 +0300 Subject: [PATCH 029/123] defer SNMP polling until transport session is fully registered --- .../server/transport/snmp/SnmpTransportContext.java | 3 ++- .../server/transport/snmp/service/SnmpTransportService.java | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java index 7d92639f1a..af5e1f04b2 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java @@ -160,7 +160,6 @@ public class SnmpTransportContext extends TransportContext { return; } sessions.put(device.getId(), sessionContext); - snmpTransportService.createQueryingTasks(sessionContext); log.info("Established SNMP device session for device {}", device.getId()); } @@ -224,6 +223,8 @@ public class SnmpTransportContext extends TransportContext { registerTransportSession(sessionContext, msg); }); transportService.lifecycleEvent(sessionContext.getTenantId(), sessionContext.getDeviceId(), ComponentLifecycleEvent.STARTED, true, null); + snmpTransportService.createQueryingTasks(sessionContext); + log.info("[{}] Session registered and querying tasks created", sessionContext.getDeviceId()); } else { log.warn("[{}] Failed to process device auth", sessionContext.getDeviceId()); } diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java index 0c39b7e740..5105b35d1d 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java @@ -164,7 +164,7 @@ public class SnmpTransportService implements TbTransportService, CommandResponde ScheduledTask scheduledTask = new ScheduledTask(); scheduledTask.init(() -> { try { - if (sessionContext.isActive()) { + if (sessionContext.isActive() && sessionContext.isConnected()) { return sendRequest(sessionContext, repeatingCommunicationConfig); } } catch (Exception e) { @@ -390,7 +390,9 @@ public class SnmpTransportService implements TbTransportService, CommandResponde JsonObject responseData = responseDataMappers.get(requestContext.getCommunicationSpec()).map(response, requestContext); if (responseData.size() == 0) { - log.warn("[{}] No values in the response", sessionContext.getDeviceId()); + log.warn("[{}] No values in the response for spec {}. Response PDUs: {}, Mappings count: {}", + sessionContext.getDeviceId(), requestContext.getCommunicationSpec(), + response, requestContext.getResponseMappings() != null ? requestContext.getResponseMappings().size() : 0); throw new IllegalArgumentException("No values in the response"); } From 77e3b5cd8d4906f9f6eefa75231a611ed9d5214f Mon Sep 17 00:00:00 2001 From: Maksym Tsymbarov Date: Fri, 3 Apr 2026 16:10:45 +0200 Subject: [PATCH 030/123] Add condition to not display "Add Telemetry" button for Entity view type --- .../home/components/attribute/attribute-table.component.html | 2 +- .../home/components/attribute/attribute-table.component.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html index 6d758b8c7b..c7d204884b 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html @@ -34,7 +34,7 @@