Browse Source

Merge 6d4605982b into 045ab11105

pull/15527/merge
Oleksandra Matviienko 5 days ago
committed by GitHub
parent
commit
a2286e423d
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 116
      application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java
  2. 5
      application/src/main/java/org/thingsboard/server/service/state/DeviceStateData.java
  3. 14
      application/src/main/resources/thingsboard.yml
  4. 336
      application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java
  5. 5
      common/data/src/main/java/org/thingsboard/server/common/data/DeviceIdInfo.java
  6. 6
      common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java
  7. 5
      dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java
  8. 9
      dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java
  9. 42
      dao/src/test/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidatorTest.java
  10. 5
      ui-ngx/src/app/modules/home/components/profile/device-profile.component.html
  11. 12
      ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts
  12. 1
      ui-ngx/src/app/shared/models/device.models.ts
  13. 2
      ui-ngx/src/assets/locale/locale.constant-en_US.json

116
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<Dev
@Autowired
@Lazy
private TelemetrySubscriptionService tsSubService;
@Autowired
@Lazy
private TbDeviceProfileCache deviceProfileCache;
@Value("#{${state.defaultInactivityTimeoutInSec} * 1000}")
private long defaultInactivityTimeoutMs;
@ -191,6 +200,7 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
private ListeningExecutorService deviceStateCallbackExecutor;
final ConcurrentMap<DeviceId, DeviceStateData> deviceStates = new ConcurrentHashMap<>();
final ConcurrentMap<DeviceProfileId, Long> profileResolvedInactivityTimeoutMs = new ConcurrentHashMap<>();
@PostConstruct
public void init() {
@ -307,15 +317,80 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
if (cleanDeviceStateIfBelongsToExternalPartition(tenantId, deviceId)) {
return;
}
if (inactivityTimeout <= 0L) {
inactivityTimeout = defaultInactivityTimeoutMs;
}
log.trace("[{}] on Device Activity Timeout Update device id {} inactivityTimeout {}", tenantId.getId(), deviceId.getId(), inactivityTimeout);
DeviceStateData stateData = getOrFetchDeviceStateData(deviceId);
stateData.getState().setInactivityTimeout(inactivityTimeout);
boolean overridden = inactivityTimeout > 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:
* <ol>
* <li>per-device server attribute (positive value supplied here),</li>
* <li>profile-level {@code inactivityTimeoutMs} (when set and positive),</li>
* <li>global YAML default ({@code state.defaultInactivityTimeoutInSec}).</li>
* </ol>
*/
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;
}
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<Dev
md.putValue("deviceLabel", device.getLabel());
md.putValue("deviceType", device.getType());
stateData.setMetaData(md);
DeviceProfileId newProfileId = device.getDeviceProfileId();
if (!Objects.equals(newProfileId, stateData.getDeviceProfileId())) {
stateData.setDeviceProfileId(newProfileId);
if (!stateData.isInactivityTimeoutOverridden()) {
long resolved = resolveInactivityTimeout(0L, device.getTenantId(), newProfileId);
stateData.getState().setInactivityTimeout(resolved);
checkAndUpdateState(device.getId(), stateData);
}
}
callback.onSuccess();
}
} else {
@ -698,7 +782,9 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
try {
long lastActivityTime = getEntryValue(data, LAST_ACTIVITY_TIME, 0L);
long inactivityAlarmTime = getEntryValue(data, INACTIVITY_ALARM_TIME, 0L);
long inactivityTimeout = getEntryValue(data, INACTIVITY_TIMEOUT, defaultInactivityTimeoutMs);
long timeoutFromAttribute = getEntryValue(data, INACTIVITY_TIMEOUT, 0L);
boolean overridden = timeoutFromAttribute > 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<Dev
.lastDisconnectTime(getEntryValue(data, LAST_DISCONNECT_TIME, 0L))
.lastActivityTime(lastActivityTime)
.lastInactivityAlarmTime(inactivityAlarmTime)
.inactivityTimeout(inactivityTimeout > 0 ? inactivityTimeout : defaultInactivityTimeoutMs)
.inactivityTimeout(resolvedTimeout)
.build();
TbMsgMetaData md = new TbMsgMetaData();
md.putValue("deviceName", device.getName());
@ -717,9 +803,12 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
.customerId(device.getCustomerId())
.tenantId(device.getTenantId())
.deviceId(device.getId())
.deviceProfileId(device.getDeviceProfileId())
.deviceCreationTime(device.getCreatedTime())
.metaData(md)
.state(deviceState).build();
.state(deviceState)
.inactivityTimeoutOverridden(overridden)
.build();
log.debug("[{}] Fetched device state from the DB {}", device.getId(), deviceStateData);
return deviceStateData;
} catch (Exception e) {
@ -778,7 +867,9 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
private DeviceStateData toDeviceStateData(EntityData ed, DeviceIdInfo deviceIdInfo) {
long lastActivityTime = getEntryValue(ed, getKeyType(), LAST_ACTIVITY_TIME, 0L);
long inactivityAlarmTime = getEntryValue(ed, getKeyType(), INACTIVITY_ALARM_TIME, 0L);
long inactivityTimeout = getEntryValue(ed, EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT, defaultInactivityTimeoutMs);
long timeoutFromAttribute = getEntryValue(ed, EntityKeyType.SERVER_ATTRIBUTE, INACTIVITY_TIMEOUT, 0L);
boolean overridden = timeoutFromAttribute > 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<Dev
.lastDisconnectTime(getEntryValue(ed, getKeyType(), LAST_DISCONNECT_TIME, 0L))
.lastActivityTime(lastActivityTime)
.lastInactivityAlarmTime(inactivityAlarmTime)
.inactivityTimeout(inactivityTimeout)
.inactivityTimeout(resolvedTimeout)
.build();
TbMsgMetaData md = new TbMsgMetaData();
md.putValue("deviceName", getEntryValue(ed, EntityKeyType.ENTITY_FIELD, "name", ""));
@ -797,9 +888,12 @@ public class DefaultDeviceStateService extends AbstractPartitionBasedService<Dev
.customerId(deviceIdInfo.getCustomerId())
.tenantId(deviceIdInfo.getTenantId())
.deviceId(deviceIdInfo.getDeviceId())
.deviceProfileId(deviceIdInfo.getDeviceProfileId())
.deviceCreationTime(getEntryValue(ed, EntityKeyType.ENTITY_FIELD, "createdTime", 0L))
.metaData(md)
.state(deviceState).build();
.state(deviceState)
.inactivityTimeoutOverridden(overridden)
.build();
}
private EntityKeyType getKeyType() {

5
application/src/main/java/org/thingsboard/server/service/state/DeviceStateData.java

@ -19,6 +19,7 @@ import lombok.Builder;
import lombok.Data;
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 org.thingsboard.server.common.msg.TbMsgMetaData;
@ -32,8 +33,10 @@ class DeviceStateData {
private final TenantId tenantId;
private final CustomerId customerId;
private final DeviceId deviceId;
private DeviceProfileId deviceProfileId;
private final long deviceCreationTime;
private TbMsgMetaData metaData;
private final DeviceState state;
private boolean inactivityTimeoutOverridden;
}

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

@ -1022,12 +1022,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.

336
application/src/test/java/org/thingsboard/server/service/state/DefaultDeviceStateServiceTest.java

@ -34,16 +34,22 @@ import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.Device;
import org.thingsboard.server.common.data.DeviceProfile;
import org.thingsboard.server.common.data.device.profile.DeviceProfileData;
import org.thingsboard.server.common.data.EntityType;
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.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.AttributesSaveResult;
import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.data.notification.rule.trigger.DeviceActivityTrigger;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.msg.TbMsg;
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;
@ -54,6 +60,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.usagestats.DefaultTbApiUsageReportClient;
import org.thingsboard.server.service.profile.TbDeviceProfileCache;
import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService;
import java.time.Duration;
@ -83,6 +90,7 @@ import static org.mockito.BDDMockito.willReturn;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
@ -120,6 +128,8 @@ class DefaultDeviceStateServiceTest {
NotificationRuleProcessor notificationRuleProcessor;
@Mock
DefaultTbApiUsageReportClient defaultTbApiUsageReportClient;
@Mock
TbDeviceProfileCache deviceProfileCache;
long defaultInactivityTimeoutMs = Duration.ofMinutes(10L).toMillis();
@ -137,6 +147,7 @@ class DefaultDeviceStateServiceTest {
void setUp() {
service = spy(new DefaultDeviceStateService(deviceService, attributesService, tsService, clusterService, partitionService, entityQueryRepository, null, defaultTbApiUsageReportClient, notificationRuleProcessor));
ReflectionTestUtils.setField(service, "tsSubService", telemetrySubscriptionService);
ReflectionTestUtils.setField(service, "deviceProfileCache", deviceProfileCache);
ReflectionTestUtils.setField(service, "defaultInactivityTimeoutMs", defaultInactivityTimeoutMs);
ReflectionTestUtils.setField(service, "defaultStateCheckIntervalInSec", 60);
ReflectionTestUtils.setField(service, "defaultActivityStatsIntervalInSec", 60);
@ -600,6 +611,331 @@ class DefaultDeviceStateServiceTest {
);
}
@Test
void givenAttributeValueIsPositive_whenOnDeviceInactivityTimeoutUpdate_thenMarksOverridden() {
// GIVEN
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
DeviceStateData stateData = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(deviceId)
.deviceProfileId(profileId)
.state(DeviceState.builder().lastActivityTime(100L).build())
.metaData(new TbMsgMetaData())
.build();
service.deviceStates.put(deviceId, stateData);
service.getPartitionedEntities(tpi).add(deviceId);
mockSuccessfulSaveAttributes();
doReturn(200L).when(service).getCurrentTimeMillis();
// WHEN
service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 5_000L);
// THEN
assertThat(stateData.isInactivityTimeoutOverridden()).isTrue();
assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(5_000L);
}
@Test
void givenAttributeRemovedAndProfileTimeoutSet_whenOnDeviceInactivityTimeoutUpdate_thenFallsBackToProfile() {
// GIVEN
long profileTimeoutMs = Duration.ofMinutes(2L).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(5_000L).build())
.metaData(new TbMsgMetaData())
.inactivityTimeoutOverridden(true)
.build();
service.deviceStates.put(deviceId, stateData);
service.getPartitionedEntities(tpi).add(deviceId);
mockSuccessfulSaveAttributes();
doReturn(200L).when(service).getCurrentTimeMillis();
// WHEN — sentinel value 0L means "attribute deleted"
service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 0L);
// THEN
assertThat(stateData.isInactivityTimeoutOverridden()).isFalse();
assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(profileTimeoutMs);
}
@Test
void givenAttributeRemovedAndNoProfileTimeout_whenOnDeviceInactivityTimeoutUpdate_thenFallsBackToYamlDefault() {
// GIVEN
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
DeviceProfile profile = profileWithInactivityTimeout(profileId, null);
when(deviceProfileCache.get(tenantId, profileId)).thenReturn(profile);
DeviceStateData stateData = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(deviceId)
.deviceProfileId(profileId)
.state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(5_000L).build())
.metaData(new TbMsgMetaData())
.inactivityTimeoutOverridden(true)
.build();
service.deviceStates.put(deviceId, stateData);
service.getPartitionedEntities(tpi).add(deviceId);
mockSuccessfulSaveAttributes();
doReturn(200L).when(service).getCurrentTimeMillis();
// WHEN
service.onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 0L);
// THEN
assertThat(stateData.isInactivityTimeoutOverridden()).isFalse();
assertThat(stateData.getState().getInactivityTimeout()).isEqualTo(defaultInactivityTimeoutMs);
}
@Test
void givenDeviceProfileUpdatedEvent_whenOnComponentLifecycleEvent_thenUpdatesNonOverriddenDevicesAndSkipsOverridden() {
// GIVEN
long newProfileTimeoutMs = Duration.ofMinutes(3L).toMillis();
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID());
DeviceProfile updatedProfile = profileWithInactivityTimeout(profileId, newProfileTimeoutMs);
when(deviceProfileCache.get(tenantId, profileId)).thenReturn(updatedProfile);
DeviceId nonOverriddenDeviceId = new DeviceId(UUID.randomUUID());
DeviceStateData nonOverridden = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(nonOverriddenDeviceId)
.deviceProfileId(profileId)
.state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(defaultInactivityTimeoutMs).build())
.metaData(new TbMsgMetaData())
.build();
DeviceId overriddenDeviceId = new DeviceId(UUID.randomUUID());
long overrideValue = 7_000L;
DeviceStateData overridden = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(overriddenDeviceId)
.deviceProfileId(profileId)
.state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(overrideValue).build())
.metaData(new TbMsgMetaData())
.inactivityTimeoutOverridden(true)
.build();
DeviceId otherProfileDeviceId = new DeviceId(UUID.randomUUID());
DeviceProfileId otherProfileId = new DeviceProfileId(UUID.randomUUID());
DeviceStateData otherProfile = DeviceStateData.builder()
.tenantId(tenantId)
.deviceId(otherProfileDeviceId)
.deviceProfileId(otherProfileId)
.state(DeviceState.builder().lastActivityTime(100L).inactivityTimeout(defaultInactivityTimeoutMs).build())
.metaData(new TbMsgMetaData())
.build();
service.deviceStates.put(nonOverriddenDeviceId, nonOverridden);
service.deviceStates.put(overriddenDeviceId, overridden);
service.deviceStates.put(otherProfileDeviceId, otherProfile);
doAnswer(inv -> 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

5
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;
}
}

6
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<DeviceProfileAlarm> 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;
}

5
dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java

@ -206,6 +206,11 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator<D
}
}
Long inactivityTimeoutMs = deviceProfile.getProfileData().getInactivityTimeoutMs();
if (inactivityTimeoutMs != null && inactivityTimeoutMs <= 0) {
throw new DataValidationException("Device profile inactivity timeout must be greater than 0!");
}
if (deviceProfile.getDefaultRuleChainId() != null) {
validateRuleChain(tenantId, deviceProfile.getTenantId(), deviceProfile.getDefaultRuleChainId());
}

9
dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java

@ -44,12 +44,17 @@ public class DefaultNativeDeviceRepository extends AbstractNativeRepository impl
@Override
public PageData<DeviceIdInfo> 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);
});
}

42
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;

5
ui-ngx/src/app/modules/home/components/profile/device-profile.component.html

@ -68,6 +68,11 @@
labelText="device-profile.default-rule-chain"
formControlName="defaultRuleChainId">
</tb-rule-chain-autocomplete>
<tb-time-unit-input
labelText="{{ 'device-profile.inactivity-timeout' | translate }}"
hintText="{{ 'device-profile.inactivity-timeout-hint' | translate }}"
formControlName="inactivityTimeoutSec">
</tb-time-unit-input>
<tb-dashboard-autocomplete
label="{{'device-profile.mobile-dashboard' | translate}}"
formControlName="defaultDashboardId">

12
ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts

@ -43,6 +43,7 @@ import { EntityId } from '@shared/models/id/entity-id';
import { OtaUpdateType } from '@shared/models/ota-package.models';
import { DashboardId } from '@shared/models/id/dashboard-id';
import { RuleChainType } from '@shared/models/rule-chain.models';
import { SECOND } from '@shared/models/time/time.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
@ -127,6 +128,7 @@ export class DeviceProfileComponent extends EntityComponent<DeviceProfile> {
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<DeviceProfile> {
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<DeviceProfile> {
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<DeviceProfile> {
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(
{

1
ui-ngx/src/app/shared/models/device.models.ts

@ -582,6 +582,7 @@ export interface DeviceProfileData {
transportConfiguration: DeviceProfileTransportConfiguration;
alarms?: Array<DeviceProfileAlarm>;
provisionConfiguration?: DeviceProvisionConfiguration;
inactivityTimeoutMs?: number;
}
export interface DeviceProfile extends BaseData<DeviceProfileId>, HasTenantId, HasVersion, ExportableEntity<DeviceProfileId> {

2
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. 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.",

Loading…
Cancel
Save