From 307d8c8f3ae5e6be5215d43eec227c622feb365e Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 28 Apr 2026 13:21:11 +0200 Subject: [PATCH 1/5] Added inactivity timeout setting to Device Profile New inactivityTimeoutMs field on DeviceProfileData. Devices in this profile inherit this value when they don't have a per-device inactivityTimeout server attribute. Resolution order at runtime: per-device attribute -> profile field -> server-level default from thingsboard.yml. Profile changes propagate via @EventListener(ComponentLifecycleMsg.class) to all cluster nodes; per-profile cache short-circuits iteration when the resolved timeout did not change. Device reassignment between profiles updates the cached deviceProfileId and re-resolves the timeout from the new profile, unless an override attribute is present. DeviceIdInfo now carries deviceProfileId; native query projects device_profile_id alongside the other id columns. UI: new "Device inactivity timeout" field on the Device Profile Details page using with sec/min/hours/days unit selector. Empty / 0 in the form maps to null in storage; backend validator allows null or > 0. @JsonIgnoreProperties(ignoreUnknown = true) added on DeviceProfileData for consistency with sibling DTOs (DeviceProfileConfiguration, DeviceProfileTransportConfiguration, DeviceProfileProvisionConfiguration) and as defense across non-IGNORE_UNKNOWN ObjectMapper instances. --- .../state/DefaultDeviceStateService.java | 116 +++++- .../server/service/state/DeviceStateData.java | 5 +- .../state/DefaultDeviceStateServiceTest.java | 336 ++++++++++++++++++ .../server/common/data/DeviceIdInfo.java | 5 +- .../device/profile/DeviceProfileData.java | 6 + .../validator/DeviceProfileDataValidator.java | 5 + .../device/DefaultNativeDeviceRepository.java | 9 +- .../DeviceProfileDataValidatorTest.java | 42 +++ .../profile/device-profile.component.html | 5 + .../profile/device-profile.component.ts | 12 + ui-ngx/src/app/shared/models/device.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 2 + 12 files changed, 529 insertions(+), 15 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index 40f453de42..b89237709a 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -34,6 +34,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; @@ -44,10 +45,12 @@ import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceIdInfo; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.exception.TenantNotFoundException; 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.id.UUIDBased; @@ -62,6 +65,7 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -72,6 +76,7 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; @@ -85,6 +90,7 @@ import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.partition.AbstractPartitionBasedService; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.util.ArrayList; @@ -168,6 +174,9 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceStates = new ConcurrentHashMap<>(); + final ConcurrentMap profileResolvedInactivityTimeoutMs = new ConcurrentHashMap<>(); @PostConstruct public void init() { @@ -307,15 +317,80 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService 0L; + long resolved = resolveInactivityTimeout(inactivityTimeout, tenantId, stateData.getDeviceProfileId()); + log.trace("[{}] on Device Activity Timeout Update device id {} inactivityTimeout {} (resolved {}, overridden {})", + tenantId.getId(), deviceId.getId(), inactivityTimeout, resolved, overridden); + stateData.setInactivityTimeoutOverridden(overridden); + stateData.getState().setInactivityTimeout(resolved); checkAndUpdateState(deviceId, stateData); } + /** + * Three-tier resolution for the device inactivity timeout: + *
    + *
  1. per-device server attribute (positive value supplied here),
  2. + *
  3. profile-level {@code inactivityTimeoutMs} (when set and positive),
  4. + *
  5. global YAML default ({@code state.defaultInactivityTimeoutInSec}).
  6. + *
+ */ + private long resolveInactivityTimeout(long fromAttributeOrZero, TenantId tenantId, DeviceProfileId profileId) { + if (fromAttributeOrZero > 0L) { + return fromAttributeOrZero; + } + Long fromProfile = profileTimeoutMs(tenantId, profileId); + if (fromProfile != null && fromProfile > 0L) { + return fromProfile; + } + return defaultInactivityTimeoutMs; + } + + private Long profileTimeoutMs(TenantId tenantId, DeviceProfileId profileId) { + if (tenantId == null || profileId == null) { + return null; + } + DeviceProfile profile = deviceProfileCache.get(tenantId, profileId); + if (profile == null || profile.getProfileData() == null) { + return null; + } + return profile.getProfileData().getInactivityTimeoutMs(); + } + + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + if (!EntityType.DEVICE_PROFILE.equals(event.getEntityId().getEntityType())) { + return; + } + DeviceProfileId profileId = new DeviceProfileId(event.getEntityId().getId()); + if (ComponentLifecycleEvent.DELETED.equals(event.getEvent())) { + profileResolvedInactivityTimeoutMs.remove(profileId); + return; + } + if (!ComponentLifecycleEvent.UPDATED.equals(event.getEvent())) { + return; + } + Long timeoutFromProfile = profileTimeoutMs(event.getTenantId(), profileId); + long resolvedNew = (timeoutFromProfile != null && timeoutFromProfile > 0L) ? timeoutFromProfile : defaultInactivityTimeoutMs; + Long previousResolved = profileResolvedInactivityTimeoutMs.put(profileId, resolvedNew); + if (previousResolved != null && previousResolved == resolvedNew) { + return; + } + deviceStateCallbackExecutor.submit(() -> deviceStates.forEach((deviceId, stateData) -> { + if (!profileId.equals(stateData.getDeviceProfileId())) { + return; + } + if (stateData.isInactivityTimeoutOverridden()) { + return; + } + if (stateData.getState().getInactivityTimeout() == resolvedNew) { + return; + } + stateData.getState().setInactivityTimeout(resolvedNew); + checkAndUpdateState(deviceId, stateData); + })); + } + @Override public void onDeviceInactivity(TenantId tenantId, DeviceId deviceId, long lastInactivityTime) { if (cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId)) { @@ -384,6 +459,15 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService 0L; + long resolvedTimeout = resolveInactivityTimeout(timeoutFromAttribute, device.getTenantId(), device.getDeviceProfileId()); // Actual active state by wall-clock will be updated outside this method. This method is only for fetching persistent state final boolean active = getEntryValue(data, ACTIVITY_STATE, false); DeviceState deviceState = DeviceState.builder() @@ -707,7 +793,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService 0 ? inactivityTimeout : defaultInactivityTimeoutMs) + .inactivityTimeout(resolvedTimeout) .build(); TbMsgMetaData md = new TbMsgMetaData(); md.putValue("deviceName", device.getName()); @@ -717,9 +803,12 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService 0L; + long resolvedTimeout = resolveInactivityTimeout(timeoutFromAttribute, deviceIdInfo.getTenantId(), deviceIdInfo.getDeviceProfileId()); // Actual active state by wall-clock will be updated outside this method. This method is only for fetching persistent state final boolean active = getEntryValue(ed, getKeyType(), ACTIVITY_STATE, false); DeviceState deviceState = DeviceState.builder() @@ -787,7 +878,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService null).when(service).checkAndUpdateState(any(DeviceId.class), any(DeviceStateData.class)); + + // WHEN + service.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenantId, profileId, ComponentLifecycleEvent.UPDATED)); + + // THEN + assertThat(nonOverridden.getState().getInactivityTimeout()).isEqualTo(newProfileTimeoutMs); + assertThat(overridden.getState().getInactivityTimeout()).isEqualTo(overrideValue); + assertThat(otherProfile.getState().getInactivityTimeout()).isEqualTo(defaultInactivityTimeoutMs); + verify(service).checkAndUpdateState(nonOverriddenDeviceId, nonOverridden); + verify(service, never()).checkAndUpdateState(eq(overriddenDeviceId), any()); + verify(service, never()).checkAndUpdateState(eq(otherProfileDeviceId), any()); + } + + @Test + void givenProfileClearedToNull_whenOnComponentLifecycleEvent_thenFallsBackToYamlDefault() { + // GIVEN + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceProfile updatedProfile = profileWithInactivityTimeout(profileId, null); + when(deviceProfileCache.get(tenantId, profileId)).thenReturn(updatedProfile); + + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceProfileId(profileId) + .state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(60_000L).build()) + .metaData(new TbMsgMetaData()) + .build(); + service.deviceStates.put(deviceId, stateData); + doAnswer(inv -> null).when(service).checkAndUpdateState(any(DeviceId.class), any(DeviceStateData.class)); + + // WHEN + service.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenantId, profileId, ComponentLifecycleEvent.UPDATED)); + + // THEN + assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(defaultInactivityTimeoutMs); + } + + @Test + void givenNonDeviceProfileEntity_whenOnComponentLifecycleEvent_thenIgnored() { + // GIVEN + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceProfileId(new DeviceProfileId(UUID.randomUUID())) + .state(DeviceState.builder().inactivityTimeout(60_000L).build()) + .metaData(new TbMsgMetaData()) + .build(); + service.deviceStates.put(deviceId, stateData); + + // WHEN + service.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenantId, deviceId, ComponentLifecycleEvent.UPDATED)); + + // THEN + verify(service, never()).checkAndUpdateState(any(DeviceId.class), any(DeviceStateData.class)); + assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(60_000L); + } + + @Test + void givenSecondProfileUpdateWithSameTimeout_whenOnComponentLifecycleEvent_thenSkipsIteration() { + // GIVEN + long profileTimeoutMs = Duration.ofMinutes(3L).toMillis(); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceProfile profile = profileWithInactivityTimeout(profileId, profileTimeoutMs); + when(deviceProfileCache.get(tenantId, profileId)).thenReturn(profile); + + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceProfileId(profileId) + .state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(defaultInactivityTimeoutMs).build()) + .metaData(new TbMsgMetaData()) + .build(); + service.deviceStates.put(deviceId, stateData); + doAnswer(inv -> null).when(service).checkAndUpdateState(any(DeviceId.class), any(DeviceStateData.class)); + + // WHEN — first event triggers iteration; second event with same resolved timeout hits cache + ComponentLifecycleMsg event = new ComponentLifecycleMsg(tenantId, profileId, ComponentLifecycleEvent.UPDATED); + service.onComponentLifecycleEvent(event); + service.onComponentLifecycleEvent(event); + + // THEN — checkAndUpdateState called only once (during first event), second event short-circuited by cache + verify(service, times(1)).checkAndUpdateState(eq(deviceId), any(DeviceStateData.class)); + } + + @Test + void givenDeviceProfileDeletedEvent_whenOnComponentLifecycleEvent_thenIgnored() { + // GIVEN + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceProfileId(profileId) + .state(DeviceState.builder().inactivityTimeout(60_000L).build()) + .metaData(new TbMsgMetaData()) + .build(); + service.deviceStates.put(deviceId, stateData); + + // WHEN + service.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenantId, profileId, ComponentLifecycleEvent.DELETED)); + + // THEN + verify(service, never()).checkAndUpdateState(any(DeviceId.class), any(DeviceStateData.class)); + assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(60_000L); + } + + @Test + void givenDeviceReassignedToAnotherProfile_whenOnQueueMsg_thenUpdatesProfileIdAndReResolvesTimeout() throws Exception { + // GIVEN + long oldProfileTimeoutMs = Duration.ofMinutes(2L).toMillis(); + long newProfileTimeoutMs = Duration.ofMinutes(7L).toMillis(); + DeviceProfileId oldProfileId = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId newProfileId = new DeviceProfileId(UUID.randomUUID()); + when(deviceProfileCache.get(tenantId, newProfileId)).thenReturn(profileWithInactivityTimeout(newProfileId, newProfileTimeoutMs)); + + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceProfileId(oldProfileId) + .state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(oldProfileTimeoutMs).build()) + .metaData(new TbMsgMetaData()) + .build(); + service.deviceStates.put(deviceId, stateData); + + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setDeviceProfileId(newProfileId); + device.setName("test-device"); + device.setLabel("label"); + device.setType("default"); + doReturn(device).when(deviceService).findDeviceById(TenantId.SYS_TENANT_ID, deviceId); + doAnswer(inv -> null).when(service).checkAndUpdateState(any(DeviceId.class), any(DeviceStateData.class)); + + // WHEN — simulate the proto path that fires on device update + TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setUpdated(true) + .build(); + TbCallback callback = mock(TbCallback.class); + service.onQueueMsg(proto, callback); + + // THEN + assertThat(stateData.getDeviceProfileId()).isEqualTo(newProfileId); + assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(newProfileTimeoutMs); + verify(service).checkAndUpdateState(deviceId, stateData); + } + + @Test + void givenDeviceReassignedAndOverridden_whenOnQueueMsg_thenUpdatesProfileIdButKeepsOverride() throws Exception { + // GIVEN + long overrideValue = 5_000L; + long newProfileTimeoutMs = Duration.ofMinutes(7L).toMillis(); + DeviceProfileId oldProfileId = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId newProfileId = new DeviceProfileId(UUID.randomUUID()); + + DeviceStateData stateData = DeviceStateData.builder() + .tenantId(tenantId) + .deviceId(deviceId) + .deviceProfileId(oldProfileId) + .state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(overrideValue).build()) + .metaData(new TbMsgMetaData()) + .inactivityTimeoutOverridden(true) + .build(); + service.deviceStates.put(deviceId, stateData); + + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setDeviceProfileId(newProfileId); + device.setName("test-device"); + device.setLabel("label"); + device.setType("default"); + doReturn(device).when(deviceService).findDeviceById(TenantId.SYS_TENANT_ID, deviceId); + + // WHEN + TransportProtos.DeviceStateServiceMsgProto proto = TransportProtos.DeviceStateServiceMsgProto.newBuilder() + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setDeviceIdMSB(deviceId.getId().getMostSignificantBits()) + .setDeviceIdLSB(deviceId.getId().getLeastSignificantBits()) + .setUpdated(true) + .build(); + TbCallback callback = mock(TbCallback.class); + service.onQueueMsg(proto, callback); + + // THEN — profileId is updated but override timeout is kept; new-profile cache is NOT consulted + assertThat(stateData.getDeviceProfileId()).isEqualTo(newProfileId); + assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(overrideValue); + verify(deviceProfileCache, never()).get(eq(tenantId), eq(newProfileId)); + } + + private static DeviceProfile profileWithInactivityTimeout(DeviceProfileId profileId, Long inactivityTimeoutMs) { + DeviceProfile profile = new DeviceProfile(profileId); + DeviceProfileData data = new DeviceProfileData(); + data.setInactivityTimeoutMs(inactivityTimeoutMs); + profile.setProfileData(data); + return profile; + } + @Test void givenStateDataIsNull_whenUpdateActivityState_thenShouldCleanupDevice() { // GIVEN diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceIdInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceIdInfo.java index 77968cff56..f947a0b0b6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceIdInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceIdInfo.java @@ -19,6 +19,7 @@ import lombok.Data; import lombok.extern.slf4j.Slf4j; 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.TenantId; import java.io.Serializable; @@ -33,10 +34,12 @@ public class DeviceIdInfo implements Serializable, HasTenantId { private final TenantId tenantId; private final CustomerId customerId; private final DeviceId deviceId; + private final DeviceProfileId deviceProfileId; - public DeviceIdInfo(UUID tenantId, UUID customerId, UUID deviceId) { + public DeviceIdInfo(UUID tenantId, UUID customerId, UUID deviceId, UUID deviceProfileId) { this.tenantId = TenantId.fromUUID(tenantId); this.customerId = customerId != null ? new CustomerId(customerId) : null; this.deviceId = new DeviceId(deviceId); + this.deviceProfileId = deviceProfileId != null ? new DeviceProfileId(deviceProfileId) : null; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java index da7282f48a..50c14885b1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.device.profile; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; @@ -24,6 +25,7 @@ import java.util.List; @Schema @Data +@JsonIgnoreProperties(ignoreUnknown = true) public class DeviceProfileData implements Serializable { private static final long serialVersionUID = -3864805547939495272L; @@ -38,5 +40,9 @@ public class DeviceProfileData implements Serializable { @Valid @Schema(hidden = true) private List alarms; + @Schema(description = "Default device inactivity timeout in milliseconds. " + + "Applied to all devices of this profile that don't have a per-device 'inactivityTimeout' server attribute. " + + "If null, the server-level default from configuration is used.", example = "600000") + private Long inactivityTimeoutMs; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 74fa56dc87..0916053041 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -206,6 +206,11 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator findDeviceIdInfos(Pageable pageable) { - String DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, customer_id as customerId, id as id FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; + String DEVICE_ID_INFO_QUERY = "SELECT tenant_id as tenantId, customer_id as customerId, id as id, device_profile_id as deviceProfileId FROM device ORDER BY created_time ASC LIMIT %s OFFSET %s"; return find(COUNT_QUERY, DEVICE_ID_INFO_QUERY, pageable, row -> { UUID id = (UUID) row.get("id"); var tenantIdObj = row.get("tenantId"); var customerIdObj = row.get("customerId"); - return new DeviceIdInfo(tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), customerIdObj != null ? (UUID) customerIdObj : null, id); + var deviceProfileIdObj = row.get("deviceProfileId"); + return new DeviceIdInfo( + tenantIdObj != null ? (UUID) tenantIdObj : TenantId.SYS_TENANT_ID.getId(), + customerIdObj != null ? (UUID) customerIdObj : null, + id, + deviceProfileIdObj != null ? (UUID) deviceProfileIdObj : null); }); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java index 34523c1829..608fbcbfe2 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java @@ -124,6 +124,48 @@ class DeviceProfileDataValidatorTest { verify(validator).validateString("Device profile name", deviceProfile.getName()); } + @Test + void testValidate_InactivityTimeoutMs_Null_Ok() { + DeviceProfile deviceProfile = baseDefaultProfile(); + deviceProfile.getProfileData().setInactivityTimeoutMs(null); + validator.validateDataImpl(tenantId, deviceProfile); + } + + @Test + void testValidate_InactivityTimeoutMs_Positive_Ok() { + DeviceProfile deviceProfile = baseDefaultProfile(); + deviceProfile.getProfileData().setInactivityTimeoutMs(1L); + validator.validateDataImpl(tenantId, deviceProfile); + } + + @Test + void testValidate_InactivityTimeoutMs_Zero_Error() { + DeviceProfile deviceProfile = baseDefaultProfile(); + deviceProfile.getProfileData().setInactivityTimeoutMs(0L); + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, deviceProfile)) + .hasMessageContaining("Device profile inactivity timeout must be greater than 0"); + } + + @Test + void testValidate_InactivityTimeoutMs_Negative_Error() { + DeviceProfile deviceProfile = baseDefaultProfile(); + deviceProfile.getProfileData().setInactivityTimeoutMs(-1L); + assertThatThrownBy(() -> validator.validateDataImpl(tenantId, deviceProfile)) + .hasMessageContaining("Device profile inactivity timeout must be greater than 0"); + } + + private DeviceProfile baseDefaultProfile() { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setName("default"); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + DeviceProfileData data = new DeviceProfileData(); + data.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + deviceProfile.setProfileData(data); + deviceProfile.setTenantId(tenantId); + return deviceProfile; + } + @Test void testValidateDeviceProfile_Lwm2mBootstrap_ShortServerId_Ok() { Integer shortServerId = 123; diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html index 00ddc65358..be3d90ba42 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html @@ -83,6 +83,11 @@ [ruleChainType]="edgeRuleChainType"> {{'device-profile.default-edge-rule-chain-hint' | translate}} + + { defaultDashboardId: [entity && entity.defaultDashboardId ? entity.defaultDashboardId.id : null, []], defaultQueueName: [entity ? entity.defaultQueueName : null, []], defaultEdgeRuleChainId: [entity && entity.defaultEdgeRuleChainId ? entity.defaultEdgeRuleChainId.id : null, []], + inactivityTimeoutSec: [this.toInactivityTimeoutSec(entity?.profileData?.inactivityTimeoutMs), []], firmwareId: [entity ? entity.firmwareId : null], softwareId: [entity ? entity.softwareId : null], description: [entity ? entity.description : '', []], @@ -214,6 +216,7 @@ export class DeviceProfileComponent extends EntityComponent { this.entityForm.patchValue({defaultDashboardId: entity.defaultDashboardId ? entity.defaultDashboardId.id : null}, {emitEvent: false}); this.entityForm.patchValue({defaultQueueName: entity.defaultQueueName}, {emitEvent: false}); this.entityForm.patchValue({defaultEdgeRuleChainId: entity.defaultEdgeRuleChainId ? entity.defaultEdgeRuleChainId.id : null}, {emitEvent: false}); + this.entityForm.patchValue({inactivityTimeoutSec: this.toInactivityTimeoutSec(entity.profileData?.inactivityTimeoutMs)}, {emitEvent: false}); this.entityForm.patchValue({firmwareId: entity.firmwareId}, {emitEvent: false}); this.entityForm.patchValue({softwareId: entity.softwareId}, {emitEvent: false}); this.entityForm.patchValue({description: entity.description}, {emitEvent: false}); @@ -229,6 +232,11 @@ export class DeviceProfileComponent extends EntityComponent { if (formValue.defaultEdgeRuleChainId) { formValue.defaultEdgeRuleChainId = new RuleChainId(formValue.defaultEdgeRuleChainId); } + if (formValue.profileData) { + const sec = formValue.inactivityTimeoutSec; + formValue.profileData.inactivityTimeoutMs = sec && sec > 0 ? sec * SECOND : null; + } + delete formValue.inactivityTimeoutSec; const deviceProvisionConfiguration: DeviceProvisionConfiguration = formValue.profileData.provisionConfiguration; formValue.provisionType = deviceProvisionConfiguration.type; formValue.provisionDeviceKey = deviceProvisionConfiguration.provisionDeviceKey; @@ -236,6 +244,10 @@ export class DeviceProfileComponent extends EntityComponent { return super.prepareFormValue(formValue); } + private toInactivityTimeoutSec(ms: number | null | undefined): number { + return ms && ms > 0 ? ms / SECOND : 0; + } + onDeviceProfileIdCopied(event) { this.store.dispatch(new ActionNotificationShow( { diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index 3cf1199980..77b28b503c 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -582,6 +582,7 @@ export interface DeviceProfileData { transportConfiguration: DeviceProfileTransportConfiguration; alarms?: Array; provisionConfiguration?: DeviceProvisionConfiguration; + inactivityTimeoutMs?: number; } export interface DeviceProfile extends BaseData, HasTenantId, HasVersion, ExportableEntity { diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index eb0f7cb2ad..8568519f39 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2479,6 +2479,8 @@ "default-rule-chain": "Default rule chain", "default-edge-rule-chain": "Default edge rule chain", "default-edge-rule-chain-hint": "Used on edge as rule chain to process incoming data for devices of this device profile", + "inactivity-timeout": "Device inactivity timeout", + "inactivity-timeout-hint": "Default for all devices in this profile that have no per-device inactivityTimeout server attribute. Leave empty to use the server default.", "mobile-dashboard": "Mobile dashboard", "mobile-dashboard-hint": "Used by mobile application as a device details dashboard", "select-queue-hint": "Select from a drop-down list.", From 1986b65fc21b7c1a8564c58dcee694ab625ec476 Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 28 Apr 2026 21:23:07 +0200 Subject: [PATCH 2/5] minor UI design changes --- .../components/profile/device-profile.component.html | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html index be3d90ba42..95d2b4f707 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html @@ -68,6 +68,11 @@ labelText="device-profile.default-rule-chain" formControlName="defaultRuleChainId"> + + @@ -83,11 +88,6 @@ [ruleChainType]="edgeRuleChainType"> {{'device-profile.default-edge-rule-chain-hint' | translate}} - - Date: Thu, 30 Apr 2026 15:41:06 +0200 Subject: [PATCH 3/5] Run profile inactivity timeout propagation synchronously --- .../server/service/state/DefaultDeviceStateService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index b89237709a..e50e5c7e17 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -376,7 +376,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService deviceStates.forEach((deviceId, stateData) -> { + deviceStates.forEach((deviceId, stateData) -> { if (!profileId.equals(stateData.getDeviceProfileId())) { return; } @@ -388,7 +388,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService Date: Thu, 30 Apr 2026 15:41:09 +0200 Subject: [PATCH 4/5] Reworded inactivity timeout hint to match displayed default --- ui-ngx/src/assets/locale/locale.constant-en_US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 8568519f39..13407c3fb4 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -2480,7 +2480,7 @@ "default-edge-rule-chain": "Default edge rule chain", "default-edge-rule-chain-hint": "Used on edge as rule chain to process incoming data for devices of this device profile", "inactivity-timeout": "Device inactivity timeout", - "inactivity-timeout-hint": "Default for all devices in this profile that have no per-device inactivityTimeout server attribute. Leave empty to use the server default.", + "inactivity-timeout-hint": "Default for all devices in this profile that have no per-device inactivityTimeout server attribute. Set to 0 to use the server default.", "mobile-dashboard": "Mobile dashboard", "mobile-dashboard-hint": "Used by mobile application as a device details dashboard", "select-queue-hint": "Select from a drop-down list.", From 6d4605982bdbfdd922351c03669b8616b87f068c Mon Sep 17 00:00:00 2001 From: Oleksandra Matviienko Date: Tue, 26 May 2026 15:53:44 +0200 Subject: [PATCH 5/5] Reworded defaultInactivityTimeoutInSec comment to describe three-tier resolution --- application/src/main/resources/thingsboard.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 8eb2671ab1..8e795fab95 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -1013,12 +1013,14 @@ audit-log: # Device state parameters state: - # Device inactivity timeout is a global configuration parameter that defines when the device will be marked as "inactive" by the server. - # The parameter value is in seconds. A user can overwrite this parameter for an individual device by setting the “inactivityTimeout” server-side attribute (NOTE: expects value in milliseconds). - # We recommend this parameter to be in sync with session inactivity timeout ("transport.sessions.inactivity_timeout" or TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT) parameter - # which is responsible for detection of the stale device connection sessions. - # The value of the session inactivity timeout parameter should be greater or equal to the device inactivity timeout. - # Note that the session inactivity timeout is set in milliseconds while device inactivity timeout is in seconds. + # Global fallback for device inactivity detection, in seconds. + # Resolution order (highest priority first): + # 1) per-device "inactivityTimeout" server-side attribute (milliseconds) + # 2) device profile "inactivityTimeoutMs" field (milliseconds) + # 3) this parameter (seconds, fallback) + # Keep this parameter in sync with the session inactivity timeout ("transport.sessions.inactivity_timeout" or TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT), + # which detects stale device transport sessions. The session inactivity timeout should be greater than or equal to the device inactivity timeout. + # The session inactivity timeout is in milliseconds; this device inactivity timeout is in seconds. defaultInactivityTimeoutInSec: "${DEFAULT_INACTIVITY_TIMEOUT:600}" defaultStateCheckIntervalInSec: "${DEFAULT_STATE_CHECK_INTERVAL:60}" # Interval for checking the device state after a specified period. Time in seconds # Controls whether we store the device 'active' flag in attributes (default) or telemetry.