Browse Source

Merge pull request #12764 from dskarzh/feature/save-attributes-strategies

Save attributes strategies
pull/12857/head
Andrew Shvayka 2 years ago
committed by GitHub
parent
commit
6dee09275f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json
  2. 5
      application/src/main/data/json/tenant/device_profile/rule_chain_template.json
  3. 5
      application/src/main/data/json/tenant/rule_chains/root_rule_chain.json
  4. 86
      application/src/main/data/upgrade/basic/schema_update.sql
  5. 17
      application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java
  6. 76
      application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java
  7. 11
      application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java
  8. 3
      application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java
  9. 3
      application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java
  10. 143
      application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java
  11. 759
      application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java
  12. 6
      common/proto/src/main/proto/queue.proto
  13. 6
      common/queue/src/test/java/org/thingsboard/server/queue/discovery/ZkDiscoveryServiceTest.java
  14. 5
      monitoring/src/main/resources/root_rule_chain.json
  15. 10
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java
  16. 23
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java
  17. 4
      rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java
  18. 18
      rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java
  19. 68
      rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java
  20. 11
      rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequestTest.java
  21. 110
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java
  22. 9
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java
  23. 12
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java
  24. 68
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java
  25. 52
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/AttributesProcessingSettings.java
  26. 47
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/ProcessingSettings.java
  27. 53
      rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/TimeseriesProcessingSettings.java
  28. 708
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeTest.java
  29. 25
      rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java
  30. 14
      ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html
  31. 60
      ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts
  32. 51
      ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html
  33. 91
      ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts
  34. 60
      ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts
  35. 1
      ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html
  36. 32
      ui-ngx/src/assets/help/en_US/rulenode/save_attributes_node_advanced.md
  37. 17
      ui-ngx/src/assets/locale/locale.constant-en_US.json

5
application/src/main/data/json/edge/rule_chains/edge_root_rule_chain.json

@ -50,8 +50,11 @@
}, },
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Client Attributes", "name": "Save Client Attributes",
"configurationVersion": 2, "configurationVersion": 3,
"configuration": { "configuration": {
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE", "scope": "CLIENT_SCOPE",
"notifyDevice": false, "notifyDevice": false,
"sendAttributesUpdatedNotification": false, "sendAttributesUpdatedNotification": false,

5
application/src/main/data/json/tenant/device_profile/rule_chain_template.json

@ -35,8 +35,11 @@
}, },
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Client Attributes", "name": "Save Client Attributes",
"configurationVersion": 2, "configurationVersion": 3,
"configuration": { "configuration": {
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE", "scope": "CLIENT_SCOPE",
"notifyDevice": false, "notifyDevice": false,
"sendAttributesUpdatedNotification": false, "sendAttributesUpdatedNotification": false,

5
application/src/main/data/json/tenant/rule_chains/root_rule_chain.json

@ -34,8 +34,11 @@
}, },
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Client Attributes", "name": "Save Client Attributes",
"configurationVersion": 2, "configurationVersion": 3,
"configuration": { "configuration": {
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE", "scope": "CLIENT_SCOPE",
"notifyDevice": false, "notifyDevice": false,
"sendAttributesUpdatedNotification": false, "sendAttributesUpdatedNotification": false,

86
application/src/main/data/upgrade/basic/schema_update.sql

@ -16,52 +16,54 @@
-- UPDATE SAVE TIME SERIES NODES START -- UPDATE SAVE TIME SERIES NODES START
DO $$ UPDATE rule_node
BEGIN SET configuration = (
-- Check if the rule_node table exists (configuration::jsonb - 'skipLatestPersistence')
IF EXISTS ( || jsonb_build_object(
SELECT 1 'processingSettings', jsonb_build_object(
FROM information_schema.tables 'type', 'ADVANCED',
WHERE table_name = 'rule_node' 'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'),
) THEN 'latest', jsonb_build_object('type', 'SKIP'),
'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE'),
'calculatedFields', jsonb_build_object('type', 'ON_EVERY_MESSAGE')
)
)
)::text,
configuration_version = 1
WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
AND configuration_version = 0
AND configuration::jsonb ->> 'skipLatestPersistence' = 'true';
UPDATE rule_node UPDATE rule_node
SET configuration = ( SET configuration = (
(configuration::jsonb - 'skipLatestPersistence') (configuration::jsonb - 'skipLatestPersistence')
|| jsonb_build_object( || jsonb_build_object(
'processingSettings', jsonb_build_object( 'processingSettings', jsonb_build_object(
'type', 'ADVANCED', 'type', 'ON_EVERY_MESSAGE'
'timeseries', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), )
'latest', jsonb_build_object('type', 'SKIP'), )
'webSockets', jsonb_build_object('type', 'ON_EVERY_MESSAGE'), )::text,
'calculatedFields', jsonb_build_object('type', 'ON_EVERY_MESSAGE') configuration_version = 1
) WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
) AND configuration_version = 0
)::text, AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL);
configuration_version = 1
WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
AND configuration_version = 0
AND configuration::jsonb ->> 'skipLatestPersistence' = 'true';
UPDATE rule_node -- UPDATE SAVE TIME SERIES NODES END
SET configuration = (
(configuration::jsonb - 'skipLatestPersistence')
|| jsonb_build_object(
'processingSettings', jsonb_build_object(
'type', 'ON_EVERY_MESSAGE'
)
)
)::text,
configuration_version = 1
WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode'
AND configuration_version = 0
AND (configuration::jsonb ->> 'skipLatestPersistence' != 'true' OR configuration::jsonb ->> 'skipLatestPersistence' IS NULL);
END IF; -- UPDATE SAVE ATTRIBUTES NODES START
END;
$$;
-- UPDATE SAVE TIME SERIES NODES END UPDATE rule_node
SET configuration = (
configuration::jsonb
|| jsonb_build_object(
'processingSettings', jsonb_build_object('type', 'ON_EVERY_MESSAGE')
)
)::text,
configuration_version = 3
WHERE type = 'org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode'
AND configuration_version = 2;
-- UPDATE SAVE ATTRIBUTES NODES END
ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1; ALTER TABLE api_usage_state ADD COLUMN IF NOT EXISTS version BIGINT DEFAULT 1;

17
application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java

@ -552,10 +552,19 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService<ToCore
proto.getScope(), KvProtoUtil.toAttributeKvList(proto.getDataList()), callback); proto.getScope(), KvProtoUtil.toAttributeKvList(proto.getDataList()), callback);
} else if (msg.hasAttrDelete()) { } else if (msg.hasAttrDelete()) {
TbAttributeDeleteProto proto = msg.getAttrDelete(); TbAttributeDeleteProto proto = msg.getAttrDelete();
subscriptionManagerService.onAttributesDelete( if (proto.hasNotifyDevice()) {
toTenantId(proto.getTenantIdMSB(), proto.getTenantIdLSB()), // handles old messages with deprecated 'notifyDevice'
TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()), subscriptionManagerService.onAttributesDelete(
proto.getScope(), proto.getKeysList(), proto.getNotifyDevice(), callback); toTenantId(proto.getTenantIdMSB(), proto.getTenantIdLSB()),
TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()),
proto.getScope(), proto.getKeysList(), proto.getNotifyDevice(), callback);
} else {
// handles new messages without 'notifyDevice'
subscriptionManagerService.onAttributesDelete(
toTenantId(proto.getTenantIdMSB(), proto.getTenantIdLSB()),
TbSubscriptionUtils.toEntityId(proto.getEntityType(), proto.getEntityIdMSB(), proto.getEntityIdLSB()),
proto.getScope(), proto.getKeysList(), callback);
}
} else if (msg.hasTsDelete()) { } else if (msg.hasTsDelete()) {
TbTimeSeriesDeleteProto proto = msg.getTsDelete(); TbTimeSeriesDeleteProto proto = msg.getTsDelete();
subscriptionManagerService.onTimeSeriesDelete( subscriptionManagerService.onTimeSeriesDelete(

76
application/src/main/java/org/thingsboard/server/service/subscription/DefaultSubscriptionManagerService.java

@ -21,7 +21,6 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmInfo;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
@ -49,8 +48,6 @@ import org.thingsboard.server.queue.discovery.event.OtherServiceShutdownEvent;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbQueueProducerProvider;
import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.state.DefaultDeviceStateService;
import org.thingsboard.server.service.state.DeviceStateService;
import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationUpdate;
import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate;
@ -76,7 +73,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene
private final TbServiceInfoProvider serviceInfoProvider; private final TbServiceInfoProvider serviceInfoProvider;
private final TbQueueProducerProvider producerProvider; private final TbQueueProducerProvider producerProvider;
private final TbLocalSubscriptionService localSubscriptionService; private final TbLocalSubscriptionService localSubscriptionService;
private final DeviceStateService deviceStateService;
private final TbClusterService clusterService; private final TbClusterService clusterService;
private final SubscriptionSchedulerComponent scheduler; private final SubscriptionSchedulerComponent scheduler;
@ -171,7 +167,7 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene
callback.onSuccess(); callback.onSuccess();
} }
public void onTimeSeriesUpdate(EntityId entityId, List<TsKvEntry> update) { private void onTimeSeriesUpdate(EntityId entityId, List<TsKvEntry> update) {
getEntityUpdatesInfo(entityId).timeSeriesUpdateTs = System.currentTimeMillis(); getEntityUpdatesInfo(entityId).timeSeriesUpdateTs = System.currentTimeMillis();
TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId); TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId);
if (subInfo != null) { if (subInfo != null) {
@ -201,42 +197,27 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene
@Override @Override
public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, TbCallback callback) { public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, TbCallback callback) {
onAttributesUpdate(tenantId, entityId, scope, attributes, true, callback);
}
@Override
public void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, boolean notifyDevice, TbCallback callback) {
getEntityUpdatesInfo(entityId).attributesUpdateTs = System.currentTimeMillis(); getEntityUpdatesInfo(entityId).attributesUpdateTs = System.currentTimeMillis();
processAttributesUpdate(entityId, scope, attributes); processAttributesUpdate(entityId, scope, attributes);
if (entityId.getEntityType() == EntityType.DEVICE) {
if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope)) {
updateDeviceInactivityTimeout(tenantId, entityId, attributes);
} else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) {
clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onUpdate(tenantId,
new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, new ArrayList<>(attributes))
, null);
}
}
callback.onSuccess(); callback.onSuccess();
} }
@Override
public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, TbCallback callback) {
onAttributesDelete(tenantId, entityId, scope, keys, false, callback);
}
@Override @Override
public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, boolean notifyDevice, TbCallback callback) { public void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, boolean notifyDevice, TbCallback callback) {
processAttributesUpdate(entityId, scope, processAttributesUpdate(entityId, scope,
keys.stream().map(key -> new BaseAttributeKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList())); keys.stream().map(key -> new BaseAttributeKvEntry(0, new StringDataEntry(key, ""))).collect(Collectors.toList()));
if (entityId.getEntityType() == EntityType.DEVICE) { if (entityId.getEntityType() == EntityType.DEVICE && TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) {
if (TbAttributeSubscriptionScope.SERVER_SCOPE.name().equalsIgnoreCase(scope) clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), scope, keys), null);
|| TbAttributeSubscriptionScope.ANY_SCOPE.name().equalsIgnoreCase(scope)) {
deleteDeviceInactivityTimeout(tenantId, entityId, keys);
} else if (TbAttributeSubscriptionScope.SHARED_SCOPE.name().equalsIgnoreCase(scope) && notifyDevice) {
clusterService.pushMsgToCore(DeviceAttributesEventNotificationMsg.onDelete(tenantId,
new DeviceId(entityId.getId()), scope, keys), null);
}
} }
callback.onSuccess(); callback.onSuccess();
} }
public void processAttributesUpdate(EntityId entityId, String scope, List<AttributeKvEntry> update) { private void processAttributesUpdate(EntityId entityId, String scope, List<AttributeKvEntry> update) {
TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId); TbEntityRemoteSubsInfo subInfo = entitySubscriptions.get(entityId);
if (subInfo != null) { if (subInfo != null) {
log.trace("[{}] Handling attributes update: {}", entityId, update); log.trace("[{}] Handling attributes update: {}", entityId, update);
@ -264,22 +245,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene
} }
} }
private void updateDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List<? extends KvEntry> kvEntries) {
for (KvEntry kvEntry : kvEntries) {
if (kvEntry.getKey().equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) {
deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), getLongValue(kvEntry));
}
}
}
private void deleteDeviceInactivityTimeout(TenantId tenantId, EntityId entityId, List<String> keys) {
for (String key : keys) {
if (key.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT)) {
deviceStateService.onDeviceInactivityTimeoutUpdate(tenantId, new DeviceId(entityId.getId()), 0);
}
}
}
@Override @Override
public void onAlarmUpdate(TenantId tenantId, EntityId entityId, AlarmInfo alarm, TbCallback callback) { public void onAlarmUpdate(TenantId tenantId, EntityId entityId, AlarmInfo alarm, TbCallback callback) {
onAlarmSubUpdate(tenantId, entityId, alarm, false, callback); onAlarmSubUpdate(tenantId, entityId, alarm, false, callback);
@ -349,29 +314,6 @@ public class DefaultSubscriptionManagerService extends TbApplicationEventListene
} }
} }
private static long getLongValue(KvEntry kve) {
switch (kve.getDataType()) {
case LONG:
return kve.getLongValue().orElse(0L);
case DOUBLE:
return kve.getDoubleValue().orElse(0.0).longValue();
case STRING:
try {
return Long.parseLong(kve.getStrValue().orElse("0"));
} catch (NumberFormatException e) {
return 0L;
}
case JSON:
try {
return Long.parseLong(kve.getJsonValue().orElse("0"));
} catch (NumberFormatException e) {
return 0L;
}
default:
return 0L;
}
}
private static <T extends KvEntry> List<T> getSubList(List<T> ts, Set<String> keys) { private static <T extends KvEntry> List<T> getSubList(List<T> ts, Set<String> keys) {
List<T> update = null; List<T> update = null;
for (T entry : ts) { for (T entry : ts) {

11
application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionManagerService.java

@ -39,8 +39,15 @@ public interface SubscriptionManagerService extends ApplicationListener<Partitio
void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, TbCallback callback); void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, TbCallback callback);
void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, boolean notifyDevice, TbCallback callback); void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, TbCallback empty);
/**
* This method is retained solely for backwards compatibility, specifically to handle
* legacy proto messages that include the notifyDevice field.
*
* @deprecated as of 4.0, this method will be removed in future releases.
*/
@Deprecated(forRemoval = true, since = "4.0")
void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, boolean notifyDevice, TbCallback empty); void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, boolean notifyDevice, TbCallback empty);
void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List<String> keys, TbCallback callback); void onTimeSeriesDelete(TenantId tenantId, EntityId entityId, List<String> keys, TbCallback callback);

3
application/src/main/java/org/thingsboard/server/service/subscription/TbSubscriptionUtils.java

@ -209,7 +209,7 @@ public class TbSubscriptionUtils {
return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build(); return ToCoreMsg.newBuilder().setToSubscriptionMgrMsg(msgBuilder.build()).build();
} }
public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List<String> keys, boolean notifyDevice) { public static ToCoreMsg toAttributesDeleteProto(TenantId tenantId, EntityId entityId, String scope, List<String> keys) {
TbAttributeDeleteProto.Builder builder = TbAttributeDeleteProto.newBuilder(); TbAttributeDeleteProto.Builder builder = TbAttributeDeleteProto.newBuilder();
builder.setEntityType(entityId.getEntityType().name()); builder.setEntityType(entityId.getEntityType().name());
builder.setEntityIdMSB(entityId.getId().getMostSignificantBits()); builder.setEntityIdMSB(entityId.getId().getMostSignificantBits());
@ -218,7 +218,6 @@ public class TbSubscriptionUtils {
builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits()); builder.setTenantIdLSB(tenantId.getId().getLeastSignificantBits());
builder.setScope(scope); builder.setScope(scope);
builder.addAllKeys(keys); builder.addAllKeys(keys);
builder.setNotifyDevice(notifyDevice);
SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder(); SubscriptionMgrMsgProto.Builder msgBuilder = SubscriptionMgrMsgProto.newBuilder();
msgBuilder.setAttrDelete(builder); msgBuilder.setAttrDelete(builder);

3
application/src/main/java/org/thingsboard/server/service/telemetry/AbstractSubscriptionService.java

@ -111,8 +111,7 @@ public abstract class AbstractSubscriptionService extends TbApplicationEventList
} }
@Override @Override
public void onFailure(Throwable t) { public void onFailure(Throwable t) {}
}
}, executor); }, executor);
} }

143
application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java

@ -31,20 +31,26 @@ import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.rule.engine.api.AttributesDeleteRequest; import org.thingsboard.rule.engine.api.AttributesDeleteRequest;
import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest; import org.thingsboard.rule.engine.api.TimeseriesDeleteRequest;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult; import org.thingsboard.server.common.data.kv.TsKvLatestRemovingResult;
import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg;
import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageReportClient;
import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.timeseries.TimeseriesService;
@ -52,10 +58,11 @@ import org.thingsboard.server.dao.util.KvUtils;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import org.thingsboard.server.service.cf.CalculatedFieldQueueService; import org.thingsboard.server.service.cf.CalculatedFieldQueueService;
import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService; import org.thingsboard.server.service.entitiy.entityview.TbEntityViewService;
import org.thingsboard.server.service.state.DefaultDeviceStateService;
import org.thingsboard.server.service.subscription.TbSubscriptionUtils; import org.thingsboard.server.service.subscription.TbSubscriptionUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -65,6 +72,11 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.function.Consumer; import java.util.function.Consumer;
import static java.util.Comparator.comparing;
import static java.util.Comparator.comparingLong;
import static java.util.Comparator.naturalOrder;
import static java.util.Comparator.nullsFirst;
/** /**
* Created by ashvayka on 27.03.18. * Created by ashvayka on 27.03.18.
*/ */
@ -78,6 +90,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
private final TbApiUsageReportClient apiUsageClient; private final TbApiUsageReportClient apiUsageClient;
private final TbApiUsageStateService apiUsageStateService; private final TbApiUsageStateService apiUsageStateService;
private final CalculatedFieldQueueService calculatedFieldQueueService; private final CalculatedFieldQueueService calculatedFieldQueueService;
private final DeviceStateManager deviceStateManager;
private ExecutorService tsCallBackExecutor; private ExecutorService tsCallBackExecutor;
@ -89,13 +102,15 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
@Lazy TbEntityViewService tbEntityViewService, @Lazy TbEntityViewService tbEntityViewService,
TbApiUsageReportClient apiUsageClient, TbApiUsageReportClient apiUsageClient,
TbApiUsageStateService apiUsageStateService, TbApiUsageStateService apiUsageStateService,
CalculatedFieldQueueService calculatedFieldQueueService) { CalculatedFieldQueueService calculatedFieldQueueService,
DeviceStateManager deviceStateManager) {
this.attrService = attrService; this.attrService = attrService;
this.tsService = tsService; this.tsService = tsService;
this.tbEntityViewService = tbEntityViewService; this.tbEntityViewService = tbEntityViewService;
this.apiUsageClient = apiUsageClient; this.apiUsageClient = apiUsageClient;
this.apiUsageStateService = apiUsageStateService; this.apiUsageStateService = apiUsageStateService;
this.calculatedFieldQueueService = calculatedFieldQueueService; this.calculatedFieldQueueService = calculatedFieldQueueService;
this.deviceStateManager = deviceStateManager;
} }
@PostConstruct @PostConstruct
@ -140,6 +155,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
EntityId entityId = request.getEntityId(); EntityId entityId = request.getEntityId();
TimeseriesSaveRequest.Strategy strategy = request.getStrategy(); TimeseriesSaveRequest.Strategy strategy = request.getStrategy();
ListenableFuture<TimeseriesSaveResult> resultFuture; ListenableFuture<TimeseriesSaveResult> resultFuture;
if (strategy.saveTimeseries() && strategy.saveLatest()) { if (strategy.saveTimeseries() && strategy.saveLatest()) {
resultFuture = tsService.save(tenantId, entityId, request.getEntries(), request.getTtl()); resultFuture = tsService.save(tenantId, entityId, request.getEntries(), request.getTtl());
} else if (strategy.saveLatest()) { } else if (strategy.saveLatest()) {
@ -176,11 +192,68 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
@Override @Override
public void saveAttributesInternal(AttributesSaveRequest request) { public void saveAttributesInternal(AttributesSaveRequest request) {
log.trace("Executing saveInternal [{}]", request); log.trace("Executing saveInternal [{}]", request);
ListenableFuture<List<Long>> saveFuture = attrService.save(request.getTenantId(), request.getEntityId(), request.getScope(), request.getEntries()); TenantId tenantId = request.getTenantId();
DonAsynchron.withCallback(saveFuture, result -> { EntityId entityId = request.getEntityId();
calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()); AttributesSaveRequest.Strategy strategy = request.getStrategy();
}, safeCallback(request.getCallback()), tsCallBackExecutor); ListenableFuture<List<Long>> resultFuture;
addWsCallback(saveFuture, success -> onAttributesUpdate(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getEntries(), request.isNotifyDevice()));
if (strategy.saveAttributes()) {
resultFuture = attrService.save(tenantId, entityId, request.getScope(), request.getEntries());
} else {
resultFuture = Futures.immediateFuture(Collections.emptyList());
}
addMainCallback(resultFuture, result -> {
if (strategy.processCalculatedFields()) {
calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback());
} else {
request.getCallback().onSuccess(null);
}
}, t -> request.getCallback().onFailure(t));
if (shouldSendSharedAttributesUpdatedNotification(request)) {
addMainCallback(resultFuture, success -> clusterService.pushMsgToCore(
DeviceAttributesEventNotificationMsg.onUpdate(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getEntries()), null
));
}
if (shouldCheckForInactivityTimeoutUpdates(request)) {
findNewInactivityTimeout(request.getEntries()).ifPresent(newInactivityTimeout ->
addMainCallback(resultFuture, success -> deviceStateManager.onDeviceInactivityTimeoutUpdate(
tenantId, new DeviceId(entityId.getId()), newInactivityTimeout, TbCallback.EMPTY)
)
);
}
if (strategy.sendWsUpdate()) {
addWsCallback(resultFuture, success -> onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries()));
}
}
private static boolean shouldSendSharedAttributesUpdatedNotification(AttributesSaveRequest request) {
return request.getStrategy().saveAttributes() && shouldSendSharedAttributesNotification(request.getEntityId(), request.getScope(), request.isNotifyDevice());
}
private static boolean shouldCheckForInactivityTimeoutUpdates(AttributesSaveRequest request) {
return request.getStrategy().saveAttributes()
&& request.getEntityId().getEntityType() == EntityType.DEVICE
&& request.getScope() == AttributeScope.SERVER_SCOPE;
}
private static Optional<Long> findNewInactivityTimeout(List<AttributeKvEntry> entries) {
return entries.stream()
.filter(entry -> Objects.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT, entry.getKey()))
// Select the entry with the highest version, or if the versions are equal, the one with the most recent update timestamp
.max(comparing(AttributeKvEntry::getVersion, nullsFirst(naturalOrder())).thenComparingLong(AttributeKvEntry::getLastUpdateTs))
.map(DefaultTelemetrySubscriptionService::parseAsLong);
}
private static long parseAsLong(KvEntry kve) {
try {
return Long.parseLong(kve.getValueAsString());
} catch (NumberFormatException e) {
return 0L;
}
} }
@Override @Override
@ -191,11 +264,45 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
@Override @Override
public void deleteAttributesInternal(AttributesDeleteRequest request) { public void deleteAttributesInternal(AttributesDeleteRequest request) {
ListenableFuture<List<String>> deleteFuture = attrService.removeAll(request.getTenantId(), request.getEntityId(), request.getScope(), request.getKeys()); TenantId tenantId = request.getTenantId();
DonAsynchron.withCallback(deleteFuture, result -> { EntityId entityId = request.getEntityId();
calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback());
}, safeCallback(request.getCallback()), tsCallBackExecutor); ListenableFuture<List<String>> deleteFuture = attrService.removeAll(tenantId, entityId, request.getScope(), request.getKeys());
addWsCallback(deleteFuture, success -> onAttributesDelete(request.getTenantId(), request.getEntityId(), request.getScope().name(), request.getKeys(), request.isNotifyDevice()));
addMainCallback(deleteFuture,
result -> calculatedFieldQueueService.pushRequestToQueue(request, result, request.getCallback()),
t -> request.getCallback().onFailure(t)
);
if (shouldSendSharedAttributesDeletedNotification(request)) {
addMainCallback(deleteFuture, success -> clusterService.pushMsgToCore(
DeviceAttributesEventNotificationMsg.onDelete(tenantId, new DeviceId(entityId.getId()), DataConstants.SHARED_SCOPE, request.getKeys()), null
));
}
if (inactivityTimeoutDeleted(request)) {
addMainCallback(deleteFuture, success -> deviceStateManager.onDeviceInactivityTimeoutUpdate(
tenantId, new DeviceId(entityId.getId()), 0L, TbCallback.EMPTY)
);
}
addWsCallback(deleteFuture, success -> onAttributesDelete(tenantId, entityId, request.getScope().name(), request.getKeys()));
}
private static boolean shouldSendSharedAttributesDeletedNotification(AttributesDeleteRequest request) {
return shouldSendSharedAttributesNotification(request.getEntityId(), request.getScope(), request.isNotifyDevice());
}
private static boolean shouldSendSharedAttributesNotification(EntityId entityId, AttributeScope scope, boolean notifyDevice) {
return entityId.getEntityType() == EntityType.DEVICE
&& scope == AttributeScope.SHARED_SCOPE
&& notifyDevice;
}
private static boolean inactivityTimeoutDeleted(AttributesDeleteRequest request) {
return request.getEntityId().getEntityType() == EntityType.DEVICE
&& request.getScope() == AttributeScope.SERVER_SCOPE
&& request.getKeys().stream().anyMatch(key -> Objects.equals(DefaultDeviceStateService.INACTIVITY_TIMEOUT, key));
} }
@Override @Override
@ -247,7 +354,7 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
if (entries != null) { if (entries != null) {
Optional<TsKvEntry> tsKvEntry = entries.stream() Optional<TsKvEntry> tsKvEntry = entries.stream()
.filter(entry -> entry.getTs() > startTs && entry.getTs() <= endTs) .filter(entry -> entry.getTs() > startTs && entry.getTs() <= endTs)
.max(Comparator.comparingLong(TsKvEntry::getTs)); .max(comparingLong(TsKvEntry::getTs));
tsKvEntry.ifPresent(entityViewLatest::add); tsKvEntry.ifPresent(entityViewLatest::add);
} }
} }
@ -280,16 +387,16 @@ public class DefaultTelemetrySubscriptionService extends AbstractSubscriptionSer
} }
} }
private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes, boolean notifyDevice) { private void onAttributesUpdate(TenantId tenantId, EntityId entityId, String scope, List<AttributeKvEntry> attributes) {
forwardToSubscriptionManagerService(tenantId, entityId, forwardToSubscriptionManagerService(tenantId, entityId,
subscriptionManagerService -> subscriptionManagerService.onAttributesUpdate(tenantId, entityId, scope, attributes, notifyDevice, TbCallback.EMPTY), subscriptionManagerService -> subscriptionManagerService.onAttributesUpdate(tenantId, entityId, scope, attributes, TbCallback.EMPTY),
() -> TbSubscriptionUtils.toAttributesUpdateProto(tenantId, entityId, scope, attributes)); () -> TbSubscriptionUtils.toAttributesUpdateProto(tenantId, entityId, scope, attributes));
} }
private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys, boolean notifyDevice) { private void onAttributesDelete(TenantId tenantId, EntityId entityId, String scope, List<String> keys) {
forwardToSubscriptionManagerService(tenantId, entityId, forwardToSubscriptionManagerService(tenantId, entityId,
subscriptionManagerService -> subscriptionManagerService.onAttributesDelete(tenantId, entityId, scope, keys, notifyDevice, TbCallback.EMPTY), subscriptionManagerService -> subscriptionManagerService.onAttributesDelete(tenantId, entityId, scope, keys, TbCallback.EMPTY),
() -> TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys, notifyDevice)); () -> TbSubscriptionUtils.toAttributesDeleteProto(tenantId, entityId, scope, keys));
} }
private void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List<TsKvEntry> ts) { private void onTimeSeriesUpdate(TenantId tenantId, EntityId entityId, List<TsKvEntry> ts) {

759
application/src/test/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionServiceTest.java

@ -24,25 +24,36 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import org.thingsboard.rule.engine.api.AttributesDeleteRequest;
import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.DeviceStateManager;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageState;
import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.EntityView;
import org.thingsboard.server.common.data.id.ApiUsageStateId; import org.thingsboard.server.common.data.id.ApiUsageStateId;
import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.EntityViewId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry;
import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry;
import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.LongDataEntry;
import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.kv.TimeseriesSaveResult;
import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.data.objects.AttributesEntityView; import org.thingsboard.server.common.data.objects.AttributesEntityView;
@ -50,6 +61,7 @@ import org.thingsboard.server.common.data.objects.TelemetryEntityView;
import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
import org.thingsboard.server.common.msg.rule.engine.DeviceAttributesEventNotificationMsg;
import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.common.stats.TbApiUsageReportClient;
import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.timeseries.TimeseriesService;
@ -73,14 +85,17 @@ import java.util.concurrent.ExecutorService;
import java.util.stream.LongStream; import java.util.stream.LongStream;
import java.util.stream.Stream; import java.util.stream.Stream;
import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
import static com.google.common.util.concurrent.Futures.immediateFuture; import static com.google.common.util.concurrent.Futures.immediateFuture;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class DefaultTelemetrySubscriptionServiceTest { class DefaultTelemetrySubscriptionServiceTest {
@ -91,7 +106,7 @@ class DefaultTelemetrySubscriptionServiceTest {
final long sampleTtl = 10_000L; final long sampleTtl = 10_000L;
final List<TsKvEntry> sampleTelemetry = List.of( final List<TsKvEntry> sampleTimeseries = List.of(
new BasicTsKvEntry(100L, new DoubleDataEntry("temperature", 65.2)), new BasicTsKvEntry(100L, new DoubleDataEntry("temperature", 65.2)),
new BasicTsKvEntry(100L, new DoubleDataEntry("humidity", 33.1)) new BasicTsKvEntry(100L, new DoubleDataEntry("humidity", 33.1))
); );
@ -124,12 +139,14 @@ class DefaultTelemetrySubscriptionServiceTest {
TbApiUsageStateService apiUsageStateService; TbApiUsageStateService apiUsageStateService;
@Mock @Mock
CalculatedFieldQueueService calculatedFieldQueueService; CalculatedFieldQueueService calculatedFieldQueueService;
@Mock
DeviceStateManager deviceStateManager;
DefaultTelemetrySubscriptionService telemetryService; DefaultTelemetrySubscriptionService telemetryService;
@BeforeEach @BeforeEach
void setup() { void setup() {
telemetryService = new DefaultTelemetrySubscriptionService(attrService, tsService, tbEntityViewService, apiUsageClient, apiUsageStateService, calculatedFieldQueueService); telemetryService = new DefaultTelemetrySubscriptionService(attrService, tsService, tbEntityViewService, apiUsageClient, apiUsageStateService, calculatedFieldQueueService, deviceStateManager);
ReflectionTestUtils.setField(telemetryService, "clusterService", clusterService); ReflectionTestUtils.setField(telemetryService, "clusterService", clusterService);
ReflectionTestUtils.setField(telemetryService, "partitionService", partitionService); ReflectionTestUtils.setField(telemetryService, "partitionService", partitionService);
ReflectionTestUtils.setField(telemetryService, "subscriptionManagerService", Optional.of(subscriptionManagerService)); ReflectionTestUtils.setField(telemetryService, "subscriptionManagerService", Optional.of(subscriptionManagerService));
@ -146,9 +163,9 @@ class DefaultTelemetrySubscriptionServiceTest {
lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)).thenReturn(tpi); lenient().when(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityId)).thenReturn(tpi);
lenient().when(tsService.save(tenantId, entityId, sampleTelemetry, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTelemetry.size(), listOfNNumbers(sampleTelemetry.size())))); lenient().when(tsService.save(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size()))));
lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTelemetry.size(), null))); lenient().when(tsService.saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), null)));
lenient().when(tsService.saveLatest(tenantId, entityId, sampleTelemetry)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTelemetry.size(), listOfNNumbers(sampleTelemetry.size())))); lenient().when(tsService.saveLatest(tenantId, entityId, sampleTimeseries)).thenReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size()))));
// mock no entity views // mock no entity views
lenient().when(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).thenReturn(immediateFuture(Collections.emptyList())); lenient().when(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).thenReturn(immediateFuture(Collections.emptyList()));
@ -179,7 +196,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(new ApiUsageStateId(UUID.randomUUID())) .entityId(new ApiUsageStateId(UUID.randomUUID()))
.entries(sampleTelemetry) .entries(sampleTimeseries)
.strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL)
.build(); .build();
@ -199,7 +216,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(new TimeseriesSaveRequest.Strategy(true, false, false, false)) .strategy(new TimeseriesSaveRequest.Strategy(true, false, false, false))
.build(); .build();
@ -208,7 +225,7 @@ class DefaultTelemetrySubscriptionServiceTest {
telemetryService.saveTimeseries(request); telemetryService.saveTimeseries(request);
// THEN // THEN
then(apiUsageClient).should().report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, sampleTelemetry.size()); then(apiUsageClient).should().report(tenantId, customerId, ApiUsageRecordKey.STORAGE_DP_COUNT, sampleTimeseries.size());
} }
@Test @Test
@ -218,7 +235,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS) .strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS)
.build(); .build();
@ -240,7 +257,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL) .strategy(TimeseriesSaveRequest.Strategy.PROCESS_ALL)
.future(future) .future(future)
@ -266,7 +283,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS) .strategy(TimeseriesSaveRequest.Strategy.LATEST_AND_WS)
.future(future) .future(future)
@ -286,12 +303,12 @@ class DefaultTelemetrySubscriptionServiceTest {
entityView.setTenantId(tenantId); entityView.setTenantId(tenantId);
entityView.setCustomerId(customerId); entityView.setCustomerId(customerId);
entityView.setEntityId(entityId); entityView.setEntityId(entityId);
entityView.setKeys(new TelemetryEntityView(sampleTelemetry.stream().map(KvEntry::getKey).toList(), new AttributesEntityView())); entityView.setKeys(new TelemetryEntityView(sampleTimeseries.stream().map(KvEntry::getKey).toList(), new AttributesEntityView()));
// mock that there is one entity view // mock that there is one entity view
given(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).willReturn(immediateFuture(List.of(entityView))); given(tbEntityViewService.findEntityViewsByTenantIdAndEntityIdAsync(tenantId, entityId)).willReturn(immediateFuture(List.of(entityView)));
// mock that save latest call for entity view is successful // mock that save latest call for entity view is successful
given(tsService.saveLatest(tenantId, entityView.getId(), sampleTelemetry)).willReturn(immediateFuture(TimeseriesSaveResult.of(sampleTelemetry.size(), listOfNNumbers(sampleTelemetry.size())))); given(tsService.saveLatest(tenantId, entityView.getId(), sampleTimeseries)).willReturn(immediateFuture(TimeseriesSaveResult.of(sampleTimeseries.size(), listOfNNumbers(sampleTimeseries.size()))));
// mock TPI for entity view // mock TPI for entity view
given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).willReturn(tpi); given(partitionService.resolve(ServiceType.TB_CORE, tenantId, entityView.getId())).willReturn(tpi);
@ -299,7 +316,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(new TimeseriesSaveRequest.Strategy(false, true, false, false)) .strategy(new TimeseriesSaveRequest.Strategy(false, true, false, false))
.build(); .build();
@ -309,12 +326,12 @@ class DefaultTelemetrySubscriptionServiceTest {
// THEN // THEN
// should save latest to both the main entity and it's entity view // should save latest to both the main entity and it's entity view
then(tsService).should().saveLatest(tenantId, entityId, sampleTelemetry); then(tsService).should().saveLatest(tenantId, entityId, sampleTimeseries);
then(tsService).should().saveLatest(tenantId, entityView.getId(), sampleTelemetry); then(tsService).should().saveLatest(tenantId, entityView.getId(), sampleTimeseries);
then(tsService).shouldHaveNoMoreInteractions(); then(tsService).shouldHaveNoMoreInteractions();
// should send WS update only for entity view (WS update for the main entity is disabled in the save request) // should send WS update only for entity view (WS update for the main entity is disabled in the save request)
then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityView.getId(), sampleTelemetry, TbCallback.EMPTY); then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityView.getId(), sampleTimeseries, TbCallback.EMPTY);
then(subscriptionManagerService).shouldHaveNoMoreInteractions(); then(subscriptionManagerService).shouldHaveNoMoreInteractions();
} }
@ -325,7 +342,7 @@ class DefaultTelemetrySubscriptionServiceTest {
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(new TimeseriesSaveRequest.Strategy(true, false, false, false)) .strategy(new TimeseriesSaveRequest.Strategy(true, false, false, false))
.build(); .build();
@ -335,7 +352,7 @@ class DefaultTelemetrySubscriptionServiceTest {
// THEN // THEN
// should save only time series for the main entity // should save only time series for the main entity
then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl); then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl);
then(tsService).shouldHaveNoMoreInteractions(); then(tsService).shouldHaveNoMoreInteractions();
// should not send any WS updates // should not send any WS updates
@ -343,14 +360,14 @@ class DefaultTelemetrySubscriptionServiceTest {
} }
@ParameterizedTest @ParameterizedTest
@MethodSource("booleanCombinations") @MethodSource("allCombinationsOfFourBooleans")
void shouldCallCorrectApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate, boolean processCalculatedFields) { void shouldCallCorrectSaveTimeseriesApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveTimeseries, boolean saveLatest, boolean sendWsUpdate, boolean processCalculatedFields) {
// GIVEN // GIVEN
var request = TimeseriesSaveRequest.builder() var request = TimeseriesSaveRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
.customerId(customerId) .customerId(customerId)
.entityId(entityId) .entityId(entityId)
.entries(sampleTelemetry) .entries(sampleTimeseries)
.ttl(sampleTtl) .ttl(sampleTtl)
.strategy(new TimeseriesSaveRequest.Strategy(saveTimeseries, saveLatest, sendWsUpdate, processCalculatedFields)) .strategy(new TimeseriesSaveRequest.Strategy(saveTimeseries, saveLatest, sendWsUpdate, processCalculatedFields))
.build(); .build();
@ -360,11 +377,11 @@ class DefaultTelemetrySubscriptionServiceTest {
// THEN // THEN
if (saveTimeseries && saveLatest) { if (saveTimeseries && saveLatest) {
then(tsService).should().save(tenantId, entityId, sampleTelemetry, sampleTtl); then(tsService).should().save(tenantId, entityId, sampleTimeseries, sampleTtl);
} else if (saveLatest) { } else if (saveLatest) {
then(tsService).should().saveLatest(tenantId, entityId, sampleTelemetry); then(tsService).should().saveLatest(tenantId, entityId, sampleTimeseries);
} else if (saveTimeseries) { } else if (saveTimeseries) {
then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTelemetry, sampleTtl); then(tsService).should().saveWithoutLatest(tenantId, entityId, sampleTimeseries, sampleTtl);
} }
if (processCalculatedFields) { if (processCalculatedFields) {
@ -374,13 +391,13 @@ class DefaultTelemetrySubscriptionServiceTest {
then(tsService).shouldHaveNoMoreInteractions(); then(tsService).shouldHaveNoMoreInteractions();
if (sendWsUpdate) { if (sendWsUpdate) {
then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityId, sampleTelemetry, TbCallback.EMPTY); then(subscriptionManagerService).should().onTimeSeriesUpdate(tenantId, entityId, sampleTimeseries, TbCallback.EMPTY);
} else { } else {
then(subscriptionManagerService).shouldHaveNoInteractions(); then(subscriptionManagerService).shouldHaveNoInteractions();
} }
} }
private static Stream<Arguments> booleanCombinations() { private static Stream<Arguments> allCombinationsOfFourBooleans() {
return Stream.of( return Stream.of(
Arguments.of(true, true, true, true), Arguments.of(true, true, true, true),
Arguments.of(true, true, true, false), Arguments.of(true, true, true, false),
@ -401,7 +418,693 @@ class DefaultTelemetrySubscriptionServiceTest {
); );
} }
// used to emulate sequence numbers returned by save latest API /* --- Save attributes API --- */
@ParameterizedTest
@MethodSource("allCombinationsOfThreeBooleans")
void shouldCallCorrectSaveAttributesApiBasedOnBooleanFlagsInTheSaveRequest(boolean saveAttributes, boolean sendWsUpdate, boolean processCalculatedFields) {
// GIVEN
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(entityId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(new DoubleDataEntry("temperature", 65.2))
.notifyDevice(false)
.strategy(new AttributesSaveRequest.Strategy(saveAttributes, sendWsUpdate, processCalculatedFields))
.build();
lenient().when(attrService.save(tenantId, entityId, request.getScope(), request.getEntries())).thenReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
if (saveAttributes) {
then(attrService).should().save(tenantId, entityId, request.getScope(), request.getEntries());
} else {
then(attrService).shouldHaveNoInteractions();
}
if (processCalculatedFields) {
then(calculatedFieldQueueService).should().pushRequestToQueue(eq(request), any(), eq(request.getCallback()));
}
if (sendWsUpdate) {
then(subscriptionManagerService).should().onAttributesUpdate(tenantId, entityId, request.getScope().name(), request.getEntries(), TbCallback.EMPTY);
} else {
then(subscriptionManagerService).shouldHaveNoInteractions();
}
}
static Stream<Arguments> allCombinationsOfThreeBooleans() {
return Stream.of(
Arguments.of(true, true, true),
Arguments.of(true, true, false),
Arguments.of(true, false, true),
Arguments.of(true, false, false),
Arguments.of(false, true, true),
Arguments.of(false, true, false),
Arguments.of(false, false, true),
Arguments.of(false, false, false)
);
}
@Test
void shouldThrowErrorWhenTryingToSaveAttributesForApiUsageState() {
// GIVEN
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(new ApiUsageStateId(UUID.randomUUID()))
.scope(AttributeScope.SHARED_SCOPE)
.entry(new DoubleDataEntry("temperature", 65.2))
.notifyDevice(true)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
// WHEN
assertThatThrownBy(() -> telemetryService.saveAttributes(request))
.isInstanceOf(RuntimeException.class)
.hasMessage("Can't update API Usage State!");
// THEN
then(attrService).shouldHaveNoInteractions();
}
@Test
void shouldSendAttributesUpdateNotificationWhenDeviceSharedAttributesAreSavedAndNotifyDeviceIsTrue() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)),
new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test"))
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.entries(entries)
.notifyDevice(true)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
var expectedAttributesUpdateMsg = DeviceAttributesEventNotificationMsg.onUpdate(tenantId, deviceId, "SHARED_SCOPE", entries);
then(clusterService).should().pushMsgToCore(eq(expectedAttributesUpdateMsg), isNull());
}
@ParameterizedTest
@EnumSource(
value = EntityType.class,
names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotSendAttributesUpdateNotificationWhenEntityIsNotDevice(EntityType entityType) {
// GIVEN
var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)),
new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test"))
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(nonDeviceId)
.scope(AttributeScope.SHARED_SCOPE)
.entries(entries)
.notifyDevice(true)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, nonDeviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@ParameterizedTest
@EnumSource(
value = AttributeScope.class,
names = "SHARED_SCOPE",
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotSendAttributesUpdateNotificationWhenAttributesAreNotShared(AttributeScope notSharedScope) {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)),
new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test"))
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(notSharedScope)
.entries(entries)
.notifyDevice(true)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotSendAttributesUpdateNotificationWhenNotifyDeviceIsFalse() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)),
new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test"))
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.entries(entries)
.notifyDevice(false)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFuture(listOfNNumbers(entries.size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotSendAttributesUpdateNotificationWhenAttributesSaveWasSkipped() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)),
new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test"))
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.entries(entries)
.notifyDevice(true)
.strategy(new AttributesSaveRequest.Strategy(false, false, false))
.build();
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotSendAttributesUpdateNotificationWhenAttributesSaveFailed() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(123L, new DoubleDataEntry("shared1", 65.2)),
new BaseAttributeKvEntry(456L, new StringDataEntry("shared2", "test"))
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.entries(entries)
.notifyDevice(true)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), entries)).willReturn(immediateFailedFuture(new RuntimeException("failed to save")));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotifyDeviceStateManagerWhenDeviceInactivityTimeoutWasUpdated() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L));
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(inactivityTimeout)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 5000L, TbCallback.EMPTY);
}
@Test
void shouldNotNotifyDeviceStateManagerWhenDeviceInactivityTimeoutSaveWasSkipped() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L));
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(inactivityTimeout)
.strategy(new AttributesSaveRequest.Strategy(false, true, true))
.build();
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@ParameterizedTest
@EnumSource(
value = EntityType.class,
names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasUpdatedButEntityTypeIsNotDevice(EntityType entityType) {
// GIVEN
var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088");
var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L));
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(nonDeviceId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(inactivityTimeout)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, nonDeviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@ParameterizedTest
@EnumSource(
value = AttributeScope.class,
names = {"SERVER_SCOPE"},
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasUpdatedButAttributeScopeIsNotServer(AttributeScope nonServerScope) {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("inactivityTimeout", 5000L));
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(nonServerScope)
.entry(inactivityTimeout)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@Test
void shouldNotNotifyDeviceStateManagerWhenUpdatedAttributesDoNotContainInactivityTimeout() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var inactivityTimeout = new BaseAttributeKvEntry(123L, new LongDataEntry("notInactivityTimeout", 5000L));
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.entry(inactivityTimeout)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@Test
void shouldUseInactivityTimeoutEntryWithTheGreatestVersion() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 0L), 0L, null),
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 1000L), 3L, 1L),
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 2000L), 2L, 2L),
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 3000L), 1L, 3L)
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.entries(entries)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 3000L, TbCallback.EMPTY);
}
@Test
void shouldUseInactivityTimeoutEntryWithTheGreatestLastUpdateTsWhenVersionsAreTheSame() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<AttributeKvEntry> entries = List.of(
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 1000L), 1L, 1L),
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 2000L), 2L, 1L),
new BaseAttributeKvEntry(new LongDataEntry("inactivityTimeout", 3000L), 3L, 1L)
);
var request = AttributesSaveRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.entries(entries)
.strategy(new AttributesSaveRequest.Strategy(true, false, false))
.build();
given(attrService.save(tenantId, deviceId, request.getScope(), request.getEntries())).willReturn(immediateFuture(listOfNNumbers(request.getEntries().size())));
// WHEN
telemetryService.saveAttributes(request);
// THEN
then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 3000L, TbCallback.EMPTY);
}
/* --- Delete attributes API --- */
@Test
void shouldThrowErrorWhenTryingToDeleteAttributesForApiUsageState() {
// GIVEN
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(new ApiUsageStateId(UUID.randomUUID()))
.scope(AttributeScope.SHARED_SCOPE)
.keys(List.of("attributeKeyToDelete1", "attributeKeyToDelete2"))
.notifyDevice(true)
.build();
// WHEN
assertThatThrownBy(() -> telemetryService.deleteAttributes(request))
.isInstanceOf(RuntimeException.class)
.hasMessage("Can't update API Usage State!");
// THEN
then(attrService).shouldHaveNoInteractions();
}
@Test
void shouldSendAttributesDeletedNotificationWhenDeviceSharedAttributesAreDeletedAndNotifyDeviceIsTrue() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<String> keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.keys(keys)
.notifyDevice(true)
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
var expectedAttributesDeletedMsg = DeviceAttributesEventNotificationMsg.onDelete(tenantId, deviceId, "SHARED_SCOPE", List.of("attributeKeyToDelete1", "attributeKeyToDelete2"));
then(clusterService).should().pushMsgToCore(eq(expectedAttributesDeletedMsg), isNull());
}
@ParameterizedTest
@EnumSource(
value = EntityType.class,
names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotSendAttributesDeletedNotificationWhenEntityIsNotDevice(EntityType entityType) {
// GIVEN
var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088");
List<String> keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(nonDeviceId)
.scope(AttributeScope.SHARED_SCOPE)
.keys(keys)
.notifyDevice(true)
.build();
given(attrService.removeAll(tenantId, nonDeviceId, request.getScope(), keys)).willReturn(immediateFuture(keys));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@ParameterizedTest
@EnumSource(
value = AttributeScope.class,
names = "SHARED_SCOPE",
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotSendAttributesDeletedNotificationWhenAttributesAreNotShared(AttributeScope notSharedScope) {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<String> keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(notSharedScope)
.keys(keys)
.notifyDevice(true)
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotSendAttributesDeletedNotificationWhenNotifyDeviceIsFalse() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<String> keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.keys(keys)
.notifyDevice(false)
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFuture(keys));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotSendAttributesDeletedNotificationWhenAttributesDeleteFailed() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
List<String> keys = List.of("attributeKeyToDelete1", "attributeKeyToDelete2");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SHARED_SCOPE)
.keys(keys)
.notifyDevice(true)
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), keys)).willReturn(immediateFailedFuture(new RuntimeException("failed to delete")));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(clusterService).should(never()).pushMsgToCore(any(), any());
}
@Test
void shouldNotifyDeviceStateManagerWhenDeviceInactivityTimeoutWasDeleted() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.keys(List.of("inactivityTimeout", "someOtherDeletedAttribute"))
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys()));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(deviceStateManager).should().onDeviceInactivityTimeoutUpdate(tenantId, deviceId, 0L, TbCallback.EMPTY);
}
@ParameterizedTest
@EnumSource(
value = EntityType.class,
names = {"DEVICE", "API_USAGE_STATE"}, // API usage state excluded due to coverage in another test
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasDeletedButEntityTypeIsNotDevice(EntityType entityType) {
// GIVEN
var nonDeviceId = EntityIdFactory.getByTypeAndUuid(entityType, "cc51e450-53e1-11ee-883e-e56b48fd2088");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(nonDeviceId)
.scope(AttributeScope.SERVER_SCOPE)
.keys(List.of("inactivityTimeout", "someOtherDeletedAttribute"))
.build();
given(attrService.removeAll(tenantId, nonDeviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys()));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@ParameterizedTest
@EnumSource(
value = AttributeScope.class,
names = {"SERVER_SCOPE"},
mode = EnumSource.Mode.EXCLUDE
)
void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasDeletedButAttributeScopeIsNotServer(AttributeScope nonServerScope) {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(nonServerScope)
.keys(List.of("inactivityTimeout", "someOtherDeletedAttribute"))
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys()));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@Test
void shouldNotNotifyDeviceStateManagerWhenInactivityTimeoutWasNotDeleted() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.keys(List.of("someOtherDeletedAttribute"))
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFuture(request.getKeys()));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
@Test
void shouldNotNotifyDeviceStateManagerWhenDeviceInactivityTimeoutDeleteFailed() {
// GIVEN
var deviceId = DeviceId.fromString("cc51e450-53e1-11ee-883e-e56b48fd2088");
var request = AttributesDeleteRequest.builder()
.tenantId(tenantId)
.entityId(deviceId)
.scope(AttributeScope.SERVER_SCOPE)
.keys(List.of("inactivityTimeout", "someOtherDeletedAttribute"))
.build();
given(attrService.removeAll(tenantId, deviceId, request.getScope(), request.getKeys())).willReturn(immediateFailedFuture(new RuntimeException("failed to delete")));
// WHEN
telemetryService.deleteAttributes(request);
// THEN
then(deviceStateManager).shouldHaveNoInteractions();
}
// used to emulate versions returned by save APIs
private static List<Long> listOfNNumbers(int N) { private static List<Long> listOfNNumbers(int N) {
return LongStream.range(0, N).boxed().toList(); return LongStream.range(0, N).boxed().toList();
} }

6
common/proto/src/main/proto/queue.proto

@ -1122,7 +1122,11 @@ message TbAttributeDeleteProto {
int64 tenantIdLSB = 5; int64 tenantIdLSB = 5;
string scope = 6; string scope = 6;
repeated string keys = 7; repeated string keys = 7;
bool notifyDevice = 8; // DEPRECATED. FOR REMOVAL
// Since 4.0, this field is no longer used.
// Device notifications are now handled directly by DefaultTelemetrySubscriptionService,
// eliminating the need to pass this parameter through the queue and proto to DefaultSubscriptionManagerService.
optional bool notifyDevice = 8 [deprecated = true];
} }
message TbTimeSeriesDeleteProto { message TbTimeSeriesDeleteProto {

6
common/queue/src/test/java/org/thingsboard/server/queue/discovery/ZkDiscoveryServiceTest.java

@ -42,7 +42,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.reset;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class ZkDiscoveryServiceTest { public class ZkDiscoveryServiceTest {

5
monitoring/src/main/resources/root_rule_chain.json

@ -39,8 +39,11 @@
"type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode", "type": "org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode",
"name": "Save Attributes", "name": "Save Attributes",
"singletonMode": false, "singletonMode": false,
"configurationVersion": 1, "configurationVersion": 3,
"configuration": { "configuration": {
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE", "scope": "CLIENT_SCOPE",
"notifyDevice": false, "notifyDevice": false,
"sendAttributesUpdatedNotification": false, "sendAttributesUpdatedNotification": false,

10
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesDeleteRequest.java

@ -21,6 +21,7 @@ import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;
import org.thingsboard.common.util.NoOpFutureCallback;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
@ -30,6 +31,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import static java.util.Objects.requireNonNullElse;
@Getter @Getter
@ToString @ToString
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@ -61,8 +64,7 @@ public class AttributesDeleteRequest implements CalculatedFieldSystemAwareReques
private TbMsgType tbMsgType; private TbMsgType tbMsgType;
private FutureCallback<Void> callback; private FutureCallback<Void> callback;
Builder() { Builder() {}
}
public Builder tenantId(TenantId tenantId) { public Builder tenantId(TenantId tenantId) {
this.tenantId = tenantId; this.tenantId = tenantId;
@ -134,7 +136,9 @@ public class AttributesDeleteRequest implements CalculatedFieldSystemAwareReques
} }
public AttributesDeleteRequest build() { public AttributesDeleteRequest build() {
return new AttributesDeleteRequest(tenantId, entityId, scope, keys, notifyDevice, previousCalculatedFieldIds, tbMsgId, tbMsgType, callback); return new AttributesDeleteRequest(
tenantId, entityId, scope, keys, notifyDevice, previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance())
);
} }
} }

23
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/AttributesSaveRequest.java

@ -21,6 +21,7 @@ import lombok.AccessLevel;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;
import org.thingsboard.common.util.NoOpFutureCallback;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CalculatedFieldId;
import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityId;
@ -33,6 +34,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import static java.util.Objects.requireNonNullElse;
@Getter @Getter
@ToString @ToString
@AllArgsConstructor(access = AccessLevel.PRIVATE) @AllArgsConstructor(access = AccessLevel.PRIVATE)
@ -43,11 +46,20 @@ public class AttributesSaveRequest implements CalculatedFieldSystemAwareRequest
private final AttributeScope scope; private final AttributeScope scope;
private final List<AttributeKvEntry> entries; private final List<AttributeKvEntry> entries;
private final boolean notifyDevice; private final boolean notifyDevice;
private final Strategy strategy;
private final List<CalculatedFieldId> previousCalculatedFieldIds; private final List<CalculatedFieldId> previousCalculatedFieldIds;
private final UUID tbMsgId; private final UUID tbMsgId;
private final TbMsgType tbMsgType; private final TbMsgType tbMsgType;
private final FutureCallback<Void> callback; private final FutureCallback<Void> callback;
public record Strategy(boolean saveAttributes, boolean sendWsUpdate, boolean processCalculatedFields) {
public static final Strategy PROCESS_ALL = new Strategy(true, true, true);
public static final Strategy WS_ONLY = new Strategy(false, true, false);
public static final Strategy SKIP_ALL = new Strategy(false, false, false);
}
public static Builder builder() { public static Builder builder() {
return new Builder(); return new Builder();
} }
@ -59,6 +71,7 @@ public class AttributesSaveRequest implements CalculatedFieldSystemAwareRequest
private AttributeScope scope; private AttributeScope scope;
private List<AttributeKvEntry> entries; private List<AttributeKvEntry> entries;
private boolean notifyDevice = true; private boolean notifyDevice = true;
private Strategy strategy;
private List<CalculatedFieldId> previousCalculatedFieldIds; private List<CalculatedFieldId> previousCalculatedFieldIds;
private UUID tbMsgId; private UUID tbMsgId;
private TbMsgType tbMsgType; private TbMsgType tbMsgType;
@ -109,6 +122,11 @@ public class AttributesSaveRequest implements CalculatedFieldSystemAwareRequest
return this; return this;
} }
public Builder strategy(Strategy strategy) {
this.strategy = strategy;
return this;
}
public Builder previousCalculatedFieldIds(List<CalculatedFieldId> previousCalculatedFieldIds) { public Builder previousCalculatedFieldIds(List<CalculatedFieldId> previousCalculatedFieldIds) {
this.previousCalculatedFieldIds = previousCalculatedFieldIds; this.previousCalculatedFieldIds = previousCalculatedFieldIds;
return this; return this;
@ -144,7 +162,10 @@ public class AttributesSaveRequest implements CalculatedFieldSystemAwareRequest
} }
public AttributesSaveRequest build() { public AttributesSaveRequest build() {
return new AttributesSaveRequest(tenantId, entityId, scope, entries, notifyDevice, previousCalculatedFieldIds, tbMsgId, tbMsgType, callback); return new AttributesSaveRequest(
tenantId, entityId, scope, entries, notifyDevice, requireNonNullElse(strategy, Strategy.PROCESS_ALL),
previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance())
);
} }
} }

4
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequest.java

@ -70,7 +70,7 @@ public class TimeseriesSaveRequest implements CalculatedFieldSystemAwareRequest
private EntityId entityId; private EntityId entityId;
private List<TsKvEntry> entries; private List<TsKvEntry> entries;
private long ttl; private long ttl;
private Strategy strategy = Strategy.PROCESS_ALL; private Strategy strategy;
private List<CalculatedFieldId> previousCalculatedFieldIds; private List<CalculatedFieldId> previousCalculatedFieldIds;
private UUID tbMsgId; private UUID tbMsgId;
private TbMsgType tbMsgType; private TbMsgType tbMsgType;
@ -152,7 +152,7 @@ public class TimeseriesSaveRequest implements CalculatedFieldSystemAwareRequest
public TimeseriesSaveRequest build() { public TimeseriesSaveRequest build() {
return new TimeseriesSaveRequest( return new TimeseriesSaveRequest(
tenantId, customerId, entityId, entries, ttl, strategy, tenantId, customerId, entityId, entries, ttl, requireNonNullElse(strategy, Strategy.PROCESS_ALL),
previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance()) previousCalculatedFieldIds, tbMsgId, tbMsgType, requireNonNullElse(callback, NoOpFutureCallback.instance())
); );
} }

18
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfigurationTest.java → rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesDeleteRequestTest.java

@ -13,17 +13,27 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
package org.thingsboard.rule.engine.telemetry; package org.thingsboard.rule.engine.api;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.NoOpFutureCallback;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class TbMsgAttributesNodeConfigurationTest { class AttributesDeleteRequestTest {
@Test @Test
void testDefaultConfig_givenUpdateAttributesOnlyOnValueChange_thenTrue_sinceVersion1() { void testDefaultCallbackIsNoOp() {
assertThat(new TbMsgAttributesNodeConfiguration().defaultConfiguration().isUpdateAttributesOnlyOnValueChange()).isTrue(); var request = AttributesDeleteRequest.builder().build();
assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance());
}
@Test
void testNullCallbackIsNoOp() {
var request = AttributesDeleteRequest.builder().callback(null).build();
assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance());
} }
} }

68
rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/AttributesSaveRequestTest.java

@ -0,0 +1,68 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.api;
import org.junit.jupiter.api.Test;
import org.thingsboard.common.util.NoOpFutureCallback;
import static org.assertj.core.api.Assertions.assertThat;
class AttributesSaveRequestTest {
@Test
void testDefaultProcessingStrategyIsProcessAll() {
var request = AttributesSaveRequest.builder().build();
assertThat(request.getStrategy()).isEqualTo(AttributesSaveRequest.Strategy.PROCESS_ALL);
}
@Test
void testNullProcessingStrategyIsProcessAll() {
var request = AttributesSaveRequest.builder().strategy(null).build();
assertThat(request.getStrategy()).isEqualTo(AttributesSaveRequest.Strategy.PROCESS_ALL);
}
@Test
void testProcessAllStrategy() {
assertThat(AttributesSaveRequest.Strategy.PROCESS_ALL).isEqualTo(new AttributesSaveRequest.Strategy(true, true, true));
}
@Test
void testWsOnlyStrategy() {
assertThat(AttributesSaveRequest.Strategy.WS_ONLY).isEqualTo(new AttributesSaveRequest.Strategy(false, true, false));
}
@Test
void testSkipAllStrategy() {
assertThat(AttributesSaveRequest.Strategy.SKIP_ALL).isEqualTo(new AttributesSaveRequest.Strategy(false, false, false));
}
@Test
void testDefaultCallbackIsNoOp() {
var request = AttributesSaveRequest.builder().build();
assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance());
}
@Test
void testNullCallbackIsNoOp() {
var request = AttributesSaveRequest.builder().callback(null).build();
assertThat(request.getCallback()).isEqualTo(NoOpFutureCallback.instance());
}
}

11
rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/TimeseriesSaveRequestTest.java

@ -23,14 +23,21 @@ import static org.assertj.core.api.Assertions.assertThat;
class TimeseriesSaveRequestTest { class TimeseriesSaveRequestTest {
@Test @Test
void testDefaultSaveStrategyIsProcessAll() { void testDefaultProcessingStrategyIsProcessAll() {
var request = TimeseriesSaveRequest.builder().build(); var request = TimeseriesSaveRequest.builder().build();
assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL); assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL);
} }
@Test @Test
void testSaveAllStrategy() { void testNullProcessingStrategyIsProcessAll() {
var request = TimeseriesSaveRequest.builder().strategy(null).build();
assertThat(request.getStrategy()).isEqualTo(TimeseriesSaveRequest.Strategy.PROCESS_ALL);
}
@Test
void testProcessAllStrategy() {
assertThat(TimeseriesSaveRequest.Strategy.PROCESS_ALL).isEqualTo(new TimeseriesSaveRequest.Strategy(true, true, true, true)); assertThat(TimeseriesSaveRequest.Strategy.PROCESS_ALL).isEqualTo(new TimeseriesSaveRequest.Strategy(true, true, true, true));
} }

110
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java

@ -23,6 +23,7 @@ import com.google.common.util.concurrent.MoreExecutors;
import com.google.gson.JsonParser; import com.google.gson.JsonParser;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.thingsboard.common.util.DonAsynchron; import org.thingsboard.common.util.DonAsynchron;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.api.AttributesSaveRequest; import org.thingsboard.rule.engine.api.AttributesSaveRequest;
import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.RuleNode;
import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbContext;
@ -30,6 +31,7 @@ import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings;
import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.adaptor.JsonConverter;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
@ -43,9 +45,14 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly;
import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY;
import static org.thingsboard.server.common.data.DataConstants.SCOPE; import static org.thingsboard.server.common.data.DataConstants.SCOPE;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_REQUEST;
@ -55,13 +62,47 @@ import static org.thingsboard.server.common.data.msg.TbMsgType.POST_ATTRIBUTES_R
type = ComponentType.ACTION, type = ComponentType.ACTION,
name = "save attributes", name = "save attributes",
configClazz = TbMsgAttributesNodeConfiguration.class, configClazz = TbMsgAttributesNodeConfiguration.class,
version = 2, version = 3,
nodeDescription = "Saves attributes data", nodeDescription = """
nodeDetails = "Saves entity attributes based on configurable scope parameter. Expects messages with 'POST_ATTRIBUTES_REQUEST' message type. " + Saves attribute data with a configurable scope and according to configured processing strategies.
"If upsert(update/insert) operation is completed successfully rule node will send the incoming message via <b>Success</b> chain, otherwise, <b>Failure</b> chain is used. " + """,
"Additionally if checkbox <b>Send attributes updated notification</b> is set to true, rule node will put the \"Attributes Updated\" " + nodeDetails = """
"event for <b>SHARED_SCOPE</b> and <b>SERVER_SCOPE</b> attributes updates to the corresponding rule engine queue." + Node performs two <strong>actions:</strong>
"Performance checkbox 'Save attributes only if the value changes' will skip attributes overwrites for values with no changes (avoid concurrent writes because this check is not transactional; will not update 'Last updated time' for skipped attributes).", <ul>
<li><strong>Attributes:</strong> save attribute data to a database.</li>
<li><strong>WebSockets:</strong> notify WebSockets subscriptions about attribute data updates.</li>
</ul>
For each <em>action</em>, three <strong>processing strategies</strong> are available:
<ul>
<li><strong>On every message:</strong> perform the action for every message.</li>
<li><strong>Deduplicate:</strong> perform the action only for the first message from a particular originator within a configurable interval.</li>
<li><strong>Skip:</strong> never perform the action.</li>
</ul>
<strong>Processing strategies</strong> are configured using <em>processing settings</em>, which support two modes:
<ul>
<li><strong>Basic</strong>
<ul>
<li><strong>On every message:</strong> applies the "On every message" strategy to all actions.</li>
<li><strong>Deduplicate:</strong> applies the "Deduplicate" strategy (with a specified interval) to all actions.</li>
<li><strong>WebSockets only:</strong> applies the "Skip" strategy to Attributes, and the "On every message" strategy to WebSockets.</li>
</ul>
</li>
<li><strong>Advanced:</strong> configure each action’s strategy independently.</li>
</ul>
Additionally:
<ul>
<li>If <b>Save attributes only if the value changes</b> is enabled, the rule node compares the received attribute value with the current stored value and skips the save operation if they match.</li>
<li>If <b>Send attributes updated notification</b> is enabled, the rule node will put the <b>Attributes Updated</b> event for <code>SHARED_SCOPE</code> and <code>SERVER_SCOPE</code> attribute updates to the queue named <code>Main</code>.</li>
<li>If <b>Force notification to the device</b> is enabled, then rule node will always notify device about <code>SHARED_SCOPE</code> attribute updates, regardless of the value of <code>notifyDevice</code> metadata property.</li>
</ul>
This node expects messages of type <code>POST_ATTRIBUTES_REQUEST</code>.
<br><br>
Output connections: <code>Success</code>, <code>Failure</code>.
""",
configDirective = "tbActionNodeAttributesConfig", configDirective = "tbActionNodeAttributesConfig",
icon = "file_upload" icon = "file_upload"
) )
@ -73,9 +114,12 @@ public class TbMsgAttributesNode implements TbNode {
private TbMsgAttributesNodeConfiguration config; private TbMsgAttributesNodeConfiguration config;
private AttributesProcessingSettings processingSettings;
@Override @Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {
this.config = TbNodeUtils.convert(configuration, TbMsgAttributesNodeConfiguration.class); config = TbNodeUtils.convert(configuration, TbMsgAttributesNodeConfiguration.class);
processingSettings = config.getProcessingSettings();
} }
@Override @Override
@ -90,11 +134,20 @@ public class TbMsgAttributesNode implements TbNode {
ctx.tellSuccess(msg); ctx.tellSuccess(msg);
return; return;
} }
AttributesSaveRequest.Strategy strategy = determineSaveStrategy(msg.getMetaDataTs(), msg.getOriginator().getId());
// short-circuit
if (!strategy.saveAttributes() && !strategy.sendWsUpdate() && !strategy.processCalculatedFields()) {
ctx.tellSuccess(msg);
return;
}
AttributeScope scope = getScope(msg.getMetaData().getValue(SCOPE)); AttributeScope scope = getScope(msg.getMetaData().getValue(SCOPE));
boolean sendAttributesUpdateNotification = checkSendNotification(scope); boolean sendAttributesUpdateNotification = checkSendNotification(scope);
if (!config.isUpdateAttributesOnlyOnValueChange()) { if (!config.isUpdateAttributesOnlyOnValueChange()) {
saveAttr(newAttributes, ctx, msg, scope, sendAttributesUpdateNotification); saveAttr(newAttributes, ctx, msg, scope, sendAttributesUpdateNotification, strategy);
return; return;
} }
@ -104,13 +157,42 @@ public class TbMsgAttributesNode implements TbNode {
DonAsynchron.withCallback(findFuture, DonAsynchron.withCallback(findFuture,
currentAttributes -> { currentAttributes -> {
List<AttributeKvEntry> attributesChanged = filterChangedAttr(currentAttributes, newAttributes); List<AttributeKvEntry> attributesChanged = filterChangedAttr(currentAttributes, newAttributes);
saveAttr(attributesChanged, ctx, msg, scope, sendAttributesUpdateNotification); saveAttr(attributesChanged, ctx, msg, scope, sendAttributesUpdateNotification, strategy);
}, },
throwable -> ctx.tellFailure(msg, throwable), throwable -> ctx.tellFailure(msg, throwable),
MoreExecutors.directExecutor()); MoreExecutors.directExecutor());
} }
void saveAttr(List<AttributeKvEntry> attributes, TbContext ctx, TbMsg msg, AttributeScope scope, boolean sendAttributesUpdateNotification) { private AttributesSaveRequest.Strategy determineSaveStrategy(long ts, UUID originatorUuid) {
if (processingSettings instanceof OnEveryMessage) {
return AttributesSaveRequest.Strategy.PROCESS_ALL;
}
if (processingSettings instanceof WebSocketsOnly) {
return AttributesSaveRequest.Strategy.WS_ONLY;
}
if (processingSettings instanceof Deduplicate deduplicate) {
boolean isFirstMsgInInterval = deduplicate.getProcessingStrategy().shouldProcess(ts, originatorUuid);
return isFirstMsgInInterval ? AttributesSaveRequest.Strategy.PROCESS_ALL : AttributesSaveRequest.Strategy.SKIP_ALL;
}
if (processingSettings instanceof Advanced advanced) {
return new AttributesSaveRequest.Strategy(
advanced.attributes().shouldProcess(ts, originatorUuid),
advanced.webSockets().shouldProcess(ts, originatorUuid),
advanced.calculatedFields().shouldProcess(ts, originatorUuid)
);
}
// should not happen
throw new IllegalArgumentException("Unknown processing settings type: " + processingSettings.getClass().getSimpleName());
}
private void saveAttr(
List<AttributeKvEntry> attributes,
TbContext ctx,
TbMsg msg,
AttributeScope scope,
boolean sendAttributesUpdateNotification,
AttributesSaveRequest.Strategy strategy
) {
if (attributes.isEmpty()) { if (attributes.isEmpty()) {
ctx.tellSuccess(msg); ctx.tellSuccess(msg);
return; return;
@ -124,6 +206,7 @@ public class TbMsgAttributesNode implements TbNode {
.scope(scope) .scope(scope)
.entries(attributes) .entries(attributes)
.notifyDevice(config.isNotifyDevice() || checkNotifyDeviceMdValue(msg.getMetaData().getValue(NOTIFY_DEVICE_METADATA_KEY))) .notifyDevice(config.isNotifyDevice() || checkNotifyDeviceMdValue(msg.getMetaData().getValue(NOTIFY_DEVICE_METADATA_KEY)))
.strategy(strategy)
.previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds())
.tbMsgId(msg.getId()) .tbMsgId(msg.getId())
.tbMsgType(msg.getInternalType()) .tbMsgType(msg.getInternalType())
@ -131,7 +214,7 @@ public class TbMsgAttributesNode implements TbNode {
.build()); .build());
} }
List<AttributeKvEntry> filterChangedAttr(List<AttributeKvEntry> currentAttributes, List<AttributeKvEntry> newAttributes) { private List<AttributeKvEntry> filterChangedAttr(List<AttributeKvEntry> currentAttributes, List<AttributeKvEntry> newAttributes) {
if (currentAttributes == null || currentAttributes.isEmpty()) { if (currentAttributes == null || currentAttributes.isEmpty()) {
return newAttributes; return newAttributes;
} }
@ -181,6 +264,9 @@ public class TbMsgAttributesNode implements TbNode {
hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, SEND_ATTRIBUTES_UPDATED_NOTIFICATION_KEY, hasChanges, false); hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, SEND_ATTRIBUTES_UPDATED_NOTIFICATION_KEY, hasChanges, false);
// update updateAttributesOnlyOnValueChange. // update updateAttributesOnlyOnValueChange.
hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, UPDATE_ATTRIBUTES_ONLY_ON_VALUE_CHANGE_KEY, hasChanges, true); hasChanges = fixEscapedBooleanConfigParameter(oldConfiguration, UPDATE_ATTRIBUTES_ONLY_ON_VALUE_CHANGE_KEY, hasChanges, true);
case 2:
hasChanges = true;
((ObjectNode) oldConfiguration).set("processingSettings", JacksonUtil.valueToTree(new OnEveryMessage()));
break; break;
default: default:
break; break;

9
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeConfiguration.java

@ -15,13 +15,20 @@
*/ */
package org.thingsboard.rule.engine.telemetry; package org.thingsboard.rule.engine.telemetry;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings;
import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.DataConstants;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage;
@Data @Data
public class TbMsgAttributesNodeConfiguration implements NodeConfiguration<TbMsgAttributesNodeConfiguration> { public class TbMsgAttributesNodeConfiguration implements NodeConfiguration<TbMsgAttributesNodeConfiguration> {
@NotNull
private AttributesProcessingSettings processingSettings;
private String scope; private String scope;
private boolean notifyDevice; private boolean notifyDevice;
@ -31,6 +38,7 @@ public class TbMsgAttributesNodeConfiguration implements NodeConfiguration<TbMsg
@Override @Override
public TbMsgAttributesNodeConfiguration defaultConfiguration() { public TbMsgAttributesNodeConfiguration defaultConfiguration() {
TbMsgAttributesNodeConfiguration configuration = new TbMsgAttributesNodeConfiguration(); TbMsgAttributesNodeConfiguration configuration = new TbMsgAttributesNodeConfiguration();
configuration.setProcessingSettings(new OnEveryMessage());
configuration.setScope(DataConstants.SERVER_SCOPE); configuration.setScope(DataConstants.SERVER_SCOPE);
configuration.setNotifyDevice(false); configuration.setNotifyDevice(false);
configuration.setSendAttributesUpdatedNotification(false); configuration.setSendAttributesUpdatedNotification(false);
@ -38,4 +46,5 @@ public class TbMsgAttributesNodeConfiguration implements NodeConfiguration<TbMsg
configuration.setUpdateAttributesOnlyOnValueChange(true); configuration.setUpdateAttributesOnlyOnValueChange(true);
return configuration; return configuration;
} }
} }

12
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java

@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; import org.thingsboard.rule.engine.api.TimeseriesSaveRequest;
import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.api.util.TbNodeUtils;
import org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings;
import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy; import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy;
import org.thingsboard.server.common.adaptor.JsonConverter; import org.thingsboard.server.common.adaptor.JsonConverter;
import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.StringUtils;
@ -45,11 +46,10 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Deduplicate; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.WebSocketsOnly;
import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST; import static org.thingsboard.server.common.data.msg.TbMsgType.POST_TELEMETRY_REQUEST;
@Slf4j @Slf4j
@ -111,7 +111,7 @@ public class TbMsgTimeseriesNode implements TbNode {
private TbContext ctx; private TbContext ctx;
private long tenantProfileDefaultStorageTtl; private long tenantProfileDefaultStorageTtl;
private ProcessingSettings processingSettings; private TimeseriesProcessingSettings processingSettings;
@Override @Override
public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException {

68
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeConfiguration.java

@ -15,23 +15,12 @@
*/ */
package org.thingsboard.rule.engine.telemetry; package org.thingsboard.rule.engine.telemetry;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import lombok.Getter;
import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.api.NodeConfiguration;
import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy; import org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings;
import java.util.Objects; import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNodeConfiguration.ProcessingSettings.WebSocketsOnly;
@Data @Data
public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration<TbMsgTimeseriesNodeConfiguration> { public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration<TbMsgTimeseriesNodeConfiguration> {
@ -39,7 +28,7 @@ public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration<TbMsg
private long defaultTTL; private long defaultTTL;
private boolean useServerTs; private boolean useServerTs;
@NotNull @NotNull
private TbMsgTimeseriesNodeConfiguration.ProcessingSettings processingSettings; private TimeseriesProcessingSettings processingSettings;
@Override @Override
public TbMsgTimeseriesNodeConfiguration defaultConfiguration() { public TbMsgTimeseriesNodeConfiguration defaultConfiguration() {
@ -50,55 +39,4 @@ public class TbMsgTimeseriesNodeConfiguration implements NodeConfiguration<TbMsg
return configuration; return configuration;
} }
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OnEveryMessage.class, name = "ON_EVERY_MESSAGE"),
@JsonSubTypes.Type(value = WebSocketsOnly.class, name = "WEBSOCKETS_ONLY"),
@JsonSubTypes.Type(value = Deduplicate.class, name = "DEDUPLICATE"),
@JsonSubTypes.Type(value = Advanced.class, name = "ADVANCED")
})
sealed interface ProcessingSettings permits OnEveryMessage, Deduplicate, WebSocketsOnly, Advanced {
record OnEveryMessage() implements ProcessingSettings {}
record WebSocketsOnly() implements ProcessingSettings {}
@Getter
final class Deduplicate implements ProcessingSettings {
private final int deduplicationIntervalSecs;
@JsonIgnore
private final ProcessingStrategy processingStrategy;
@JsonCreator
Deduplicate(@JsonProperty("deduplicationIntervalSecs") int deduplicationIntervalSecs) {
this.deduplicationIntervalSecs = deduplicationIntervalSecs;
processingStrategy = ProcessingStrategy.deduplicate(deduplicationIntervalSecs);
}
}
record Advanced(
ProcessingStrategy timeseries,
ProcessingStrategy latest,
ProcessingStrategy webSockets,
ProcessingStrategy calculatedFields
) implements ProcessingSettings {
public Advanced {
Objects.requireNonNull(timeseries);
Objects.requireNonNull(latest);
Objects.requireNonNull(webSockets);
Objects.requireNonNull(calculatedFields);
}
}
}
} }

52
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/AttributesProcessingSettings.java

@ -0,0 +1,52 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.telemetry.settings;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy;
import java.util.Objects;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OnEveryMessage.class, name = "ON_EVERY_MESSAGE"),
@JsonSubTypes.Type(value = WebSocketsOnly.class, name = "WEBSOCKETS_ONLY"),
@JsonSubTypes.Type(value = Deduplicate.class, name = "DEDUPLICATE"),
@JsonSubTypes.Type(value = Advanced.class, name = "ADVANCED")
})
public sealed interface AttributesProcessingSettings extends ProcessingSettings permits OnEveryMessage, Deduplicate, WebSocketsOnly, Advanced {
record Advanced(ProcessingStrategy attributes, ProcessingStrategy webSockets, ProcessingStrategy calculatedFields) implements AttributesProcessingSettings {
public Advanced {
Objects.requireNonNull(attributes);
Objects.requireNonNull(webSockets);
Objects.requireNonNull(calculatedFields);
}
}
}

47
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/ProcessingSettings.java

@ -0,0 +1,47 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.telemetry.settings;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy;
sealed interface ProcessingSettings permits TimeseriesProcessingSettings, AttributesProcessingSettings {
record OnEveryMessage() implements TimeseriesProcessingSettings, AttributesProcessingSettings {}
record WebSocketsOnly() implements TimeseriesProcessingSettings, AttributesProcessingSettings {}
@Getter
final class Deduplicate implements TimeseriesProcessingSettings, AttributesProcessingSettings {
private final int deduplicationIntervalSecs;
@JsonIgnore
private final ProcessingStrategy processingStrategy;
@JsonCreator
public Deduplicate(@JsonProperty("deduplicationIntervalSecs") int deduplicationIntervalSecs) {
this.deduplicationIntervalSecs = deduplicationIntervalSecs;
this.processingStrategy = ProcessingStrategy.deduplicate(deduplicationIntervalSecs);
}
}
}

53
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/settings/TimeseriesProcessingSettings.java

@ -0,0 +1,53 @@
/**
* Copyright © 2016-2025 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.rule.engine.telemetry.settings;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy;
import java.util.Objects;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OnEveryMessage.class, name = "ON_EVERY_MESSAGE"),
@JsonSubTypes.Type(value = WebSocketsOnly.class, name = "WEBSOCKETS_ONLY"),
@JsonSubTypes.Type(value = Deduplicate.class, name = "DEDUPLICATE"),
@JsonSubTypes.Type(value = Advanced.class, name = "ADVANCED")
})
public sealed interface TimeseriesProcessingSettings extends ProcessingSettings permits OnEveryMessage, Deduplicate, WebSocketsOnly, Advanced {
record Advanced(ProcessingStrategy timeseries, ProcessingStrategy latest, ProcessingStrategy webSockets, ProcessingStrategy calculatedFields) implements TimeseriesProcessingSettings {
public Advanced {
Objects.requireNonNull(timeseries);
Objects.requireNonNull(latest);
Objects.requireNonNull(webSockets);
Objects.requireNonNull(calculatedFields);
}
}
}

708
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNodeTest.java

@ -15,13 +15,15 @@
*/ */
package org.thingsboard.rule.engine.telemetry; package org.thingsboard.rule.engine.telemetry;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest;
import org.thingsboard.rule.engine.api.RuleEngineTelemetryService; import org.thingsboard.rule.engine.api.RuleEngineTelemetryService;
@ -29,6 +31,7 @@ import org.thingsboard.rule.engine.api.TbContext;
import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNode;
import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeConfiguration;
import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.TbNodeException;
import org.thingsboard.rule.engine.telemetry.strategy.ProcessingStrategy;
import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.AttributeScope;
import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantId;
@ -42,185 +45,660 @@ import org.thingsboard.server.common.data.kv.StringDataEntry;
import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbMsgType;
import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsg;
import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgMetaData;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.ConstraintValidator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.stream.Stream; import java.util.stream.Stream;
import static com.google.common.util.concurrent.Futures.immediateFuture;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.assertArg; import static org.mockito.ArgumentMatchers.assertArg;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.willCallRealMethod; import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock; import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.thingsboard.rule.engine.api.AttributesSaveRequest.Strategy;
import static org.mockito.Mockito.when; import static org.thingsboard.rule.engine.api.AttributesSaveRequest.builder;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.settings.AttributesProcessingSettings.WebSocketsOnly;
import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY; import static org.thingsboard.server.common.data.DataConstants.NOTIFY_DEVICE_METADATA_KEY;
@Slf4j @ExtendWith(MockitoExtension.class)
class TbMsgAttributesNodeTest extends AbstractRuleNodeUpgradeTest { class TbMsgAttributesNodeTest extends AbstractRuleNodeUpgradeTest {
private TenantId tenantId; final TenantId tenantId = TenantId.fromUUID(UUID.fromString("6c18691e-4470-4766-9739-aface71d761f"));
private DeviceId deviceId; final DeviceId deviceId = new DeviceId(UUID.fromString("b66159d7-c77e-45e8-bb41-a8f557f434c1"));
private TbMsgAttributesNode node;
@Spy
TbMsgAttributesNode node;
TbMsgAttributesNodeConfiguration config;
@Mock
TbContext ctxMock;
@Mock
AttributesService attributesServiceMock;
@Mock
RuleEngineTelemetryService telemetryServiceMock;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
tenantId = new TenantId(UUID.fromString("6c18691e-4470-4766-9739-aface71d761f")); lenient().when(ctxMock.getTenantId()).thenReturn(tenantId);
deviceId = new DeviceId(UUID.fromString("b66159d7-c77e-45e8-bb41-a8f557f434c1")); lenient().when(ctxMock.getAttributesService()).thenReturn(attributesServiceMock);
node = spy(TbMsgAttributesNode.class); lenient().when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
config = new TbMsgAttributesNodeConfiguration().defaultConfiguration();
}
@Test
void verifyDefaultConfig() {
assertThat(config.getProcessingSettings()).isInstanceOf(OnEveryMessage.class);
assertThat(config.getScope()).isEqualTo("SERVER_SCOPE");
assertThat(config.isNotifyDevice()).isFalse();
assertThat(config.isSendAttributesUpdatedNotification()).isFalse();
assertThat(config.isUpdateAttributesOnlyOnValueChange()).isTrue();
} }
@Test @Test
void testFilterChangedAttr_whenCurrentAttributesEmpty_thenReturnNewAttributes() { void givenProcessingSettingsAreNull_whenValidatingConstraints_thenThrowsException() {
List<AttributeKvEntry> newAttributes = new ArrayList<>(); // GIVEN
config.setProcessingSettings(null);
List<AttributeKvEntry> filtered = node.filterChangedAttr(Collections.emptyList(), newAttributes); // WHEN-THEN
assertThat(filtered).isSameAs(newAttributes); assertThatThrownBy(() -> ConstraintValidator.validateFields(config))
.isInstanceOf(DataValidationException.class)
.hasMessage("Validation error: processingSettings must not be null");
} }
@Test @Test
void testFilterChangedAttr_whenCurrentAttributesContainsInAnyOrderNewAttributes_thenReturnEmptyList() { void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException {
List<AttributeKvEntry> currentAttributes = List.of( // GIVEN
new BaseAttributeKvEntry(1694000000L, new StringDataEntry("address", "Peremohy ave 1")), config.setUpdateAttributesOnlyOnValueChange(false);
new BaseAttributeKvEntry(1694000000L, new BooleanDataEntry("valid", true)), config.setProcessingSettings(new OnEveryMessage());
new BaseAttributeKvEntry(1694000000L, new LongDataEntry("counter", 100L)),
new BaseAttributeKvEntry(1694000000L, new DoubleDataEntry("temp", -18.35)), node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
new BaseAttributeKvEntry(1694000000L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}"))
); var msg = TbMsg.newMsg()
List<AttributeKvEntry> newAttributes = new ArrayList<>(currentAttributes); .type(TbMsgType.POST_ATTRIBUTES_REQUEST)
newAttributes.add(newAttributes.get(0)); .originator(deviceId)
newAttributes.remove(0); .data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
assertThat(newAttributes).hasSize(currentAttributes.size()); .metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false")))
assertThat(currentAttributes).isNotEmpty(); .build();
assertThat(newAttributes).containsExactlyInAnyOrderElementsOf(currentAttributes);
// WHEN-THEN
List<AttributeKvEntry> filtered = node.filterChangedAttr(currentAttributes, newAttributes); var expectedSaveRequest = builder()
assertThat(filtered).isEmpty(); //no changes .tenantId(tenantId)
.entityId(msg.getOriginator())
.scope(AttributeScope.valueOf(config.getScope()))
.entry(new DoubleDataEntry("temperature", 22.3))
.notifyDevice(false)
.strategy(Strategy.PROCESS_ALL)
.previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds())
.tbMsgId(msg.getId())
.tbMsgType(msg.getInternalType())
.build();
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
}
@Test
void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException {
// GIVEN
config.setUpdateAttributesOnlyOnValueChange(false);
config.setProcessingSettings(new Deduplicate(10));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false")))
.build();
// WHEN-THEN
var expectedSaveRequest = builder()
.tenantId(tenantId)
.entityId(msg.getOriginator())
.scope(AttributeScope.valueOf(config.getScope()))
.entry(new DoubleDataEntry("temperature", 22.3))
.notifyDevice(false)
.strategy(Strategy.PROCESS_ALL)
.previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds())
.tbMsgId(msg.getId())
.tbMsgType(msg.getInternalType())
.build();
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should().saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
clearInvocations(telemetryServiceMock, ctxMock);
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(never()).saveAttributes(any());
}
@Test
void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException {
// GIVEN
config.setUpdateAttributesOnlyOnValueChange(false);
config.setProcessingSettings(new WebSocketsOnly());
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false")))
.build();
// WHEN-THEN
var expectedSaveRequest = builder()
.tenantId(tenantId)
.entityId(msg.getOriginator())
.scope(AttributeScope.valueOf(config.getScope()))
.entry(new DoubleDataEntry("temperature", 22.3))
.notifyDevice(false)
.strategy(Strategy.WS_ONLY)
.previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds())
.tbMsgId(msg.getId())
.tbMsgType(msg.getInternalType())
.build();
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
}
@Test
void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException {
// GIVEN
config.setUpdateAttributesOnlyOnValueChange(false);
config.setProcessingSettings(new Advanced(
ProcessingStrategy.onEveryMessage(),
ProcessingStrategy.onEveryMessage(),
ProcessingStrategy.onEveryMessage()
));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false")))
.build();
// WHEN-THEN
var expectedSaveRequest = builder()
.tenantId(tenantId)
.entityId(msg.getOriginator())
.scope(AttributeScope.valueOf(config.getScope()))
.entry(new DoubleDataEntry("temperature", 22.3))
.notifyDevice(false)
.strategy(Strategy.PROCESS_ALL)
.previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds())
.tbMsgId(msg.getId())
.tbMsgType(msg.getInternalType())
.build();
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(times(1)).saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(times(2)).saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest)
.usingRecursiveComparison()
.ignoringFields("callback", "entries.lastUpdateTs")
.isEqualTo(expectedSaveRequest)
));
}
@Test
void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException {
// GIVEN
config.setUpdateAttributesOnlyOnValueChange(false);
config.setProcessingSettings(new Advanced(
ProcessingStrategy.deduplicate(1),
ProcessingStrategy.deduplicate(2),
ProcessingStrategy.deduplicate(3)
));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
long ts1 = 500L;
long ts2 = 1500L;
long ts3 = 2500L;
long ts4 = 3500L;
// WHEN-THEN
node.onMsg(ctxMock, TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts1))))
.build());
then(telemetryServiceMock).should().saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(Strategy.PROCESS_ALL)
));
clearInvocations(telemetryServiceMock);
node.onMsg(ctxMock, TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts2))))
.build());
then(telemetryServiceMock).should().saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, false, false))
));
clearInvocations(telemetryServiceMock);
node.onMsg(ctxMock, TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts3))))
.build());
then(telemetryServiceMock).should().saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, true, false))
));
clearInvocations(telemetryServiceMock);
node.onMsg(ctxMock, TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of("ts", Long.toString(ts4))))
.build());
then(telemetryServiceMock).should().saveAttributes(assertArg(
actualSaveRequest -> assertThat(actualSaveRequest.getStrategy()).isEqualTo(new Strategy(true, false, true))
));
} }
@Test @Test
void testFilterChangedAttr_whenCurrentAttributesContainsInAnyOrderNewAttributes_thenReturnExpectedList() { public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException {
// GIVEN
config.setProcessingSettings(new Advanced(
ProcessingStrategy.skip(),
ProcessingStrategy.skip(),
ProcessingStrategy.skip()
));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(JacksonUtil.newObjectNode().put("temperature", 22.3).toString())
.metaData(new TbMsgMetaData(Map.of(NOTIFY_DEVICE_METADATA_KEY, "false")))
.build();
// WHEN-THEN
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(never()).saveAttributes(any());
then(ctxMock).should(times(1)).tellSuccess(msg);
node.onMsg(ctxMock, msg);
then(telemetryServiceMock).should(never()).saveAttributes(any());
then(ctxMock).should(times(2)).tellSuccess(msg);
}
@Test
void givenVariousChangesToAttributes_whenUpdateOnlyOnValueChangeEnabled_thenShouldCorrectlyFilterChangedAttributes() throws TbNodeException {
// GIVEN
config.setUpdateAttributesOnlyOnValueChange(true);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
List<AttributeKvEntry> currentAttributes = List.of( List<AttributeKvEntry> currentAttributes = List.of(
new BaseAttributeKvEntry(1694000000L, new StringDataEntry("address", "Peremohy ave 1")), new BaseAttributeKvEntry(123L, new StringDataEntry("address", "Prospect Beresteiskyi 1")),
new BaseAttributeKvEntry(1694000000L, new BooleanDataEntry("valid", true)), new BaseAttributeKvEntry(123L, new BooleanDataEntry("valid", true)),
new BaseAttributeKvEntry(1694000000L, new LongDataEntry("counter", 100L)), new BaseAttributeKvEntry(123L, new LongDataEntry("counter", 100L)),
new BaseAttributeKvEntry(1694000000L, new DoubleDataEntry("temp", -18.35)), new BaseAttributeKvEntry(123L, new DoubleDataEntry("temp", -18.35)),
new BaseAttributeKvEntry(1694000000L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}")) new BaseAttributeKvEntry(123L, new JsonDataEntry("json", "{\"warning\":\"out of paper\"}"))
); );
List<AttributeKvEntry> newAttributes = List.of( given(attributesServiceMock.find(eq(tenantId), eq(deviceId), eq(AttributeScope.valueOf(config.getScope())), anyList())).willReturn(immediateFuture(currentAttributes));
new BaseAttributeKvEntry(1694000999L, new JsonDataEntry("json", "{\"status\":\"OK\"}")), // value changed, reordered
new BaseAttributeKvEntry(1694000999L, new StringDataEntry("valid", "true")), //type changed var data = JacksonUtil.newObjectNode()
new BaseAttributeKvEntry(1694000999L, new LongDataEntry("counter", 101L)), //value changed .put("address", "Prospect Beresteiskyi 1") // no changes
new BaseAttributeKvEntry(1694000999L, new DoubleDataEntry("temp", -18.35)), .put("valid", "false") // type and value changed
new BaseAttributeKvEntry(1694000999L, new StringDataEntry("address", "Peremohy ave 1")) // reordered .put("counter", 101L) // value changed
); .put("temp", -18.35) // no changes
List<AttributeKvEntry> expected = List.of( .put("json", "{\"warning\":\"out of paper\"}") // only type changed
new BaseAttributeKvEntry(1694000999L, new StringDataEntry("valid", "true")), .put("newKey", "newValue"); // new attribute
new BaseAttributeKvEntry(1694000999L, new LongDataEntry("counter", 101L)),
new BaseAttributeKvEntry(1694000999L, new JsonDataEntry("json", "{\"status\":\"OK\"}")) var msg = TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(data.toString())
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
node.onMsg(ctxMock, msg);
// THEN
List<AttributeKvEntry> expectedChangedAttributes = List.of(
new BaseAttributeKvEntry(456L, new StringDataEntry("valid", "false")),
new BaseAttributeKvEntry(456L, new LongDataEntry("counter", 101L)),
new BaseAttributeKvEntry(456L, new StringDataEntry("json", "{\"warning\":\"out of paper\"}")),
new BaseAttributeKvEntry(456L, new StringDataEntry("newKey", "newValue"))
); );
List<AttributeKvEntry> filtered = node.filterChangedAttr(currentAttributes, newAttributes); then(telemetryServiceMock).should().saveAttributes(assertArg(request ->
assertThat(filtered).containsExactlyInAnyOrderElementsOf(expected); assertThat(request.getEntries())
.usingRecursiveComparison()
.ignoringCollectionOrder()
.ignoringFields("lastUpdateTs")
.isEqualTo(expectedChangedAttributes)
));
} }
// Notify device backward-compatibility test arguments @Test
private static Stream<Arguments> givenNotifyDeviceMdValue_whenSaveAndNotify_thenVerifyExpectedArgumentForNotifyDeviceInSaveAndNotifyMethod() { void givenNoChangesToAttributes_whenUpdateOnlyOnValueChangeEnabled_thenShouldNotCallSaveAndJustTellSuccess() throws TbNodeException {
return Stream.of( // GIVEN
Arguments.of(null, true), config.setUpdateAttributesOnlyOnValueChange(true);
Arguments.of("null", false), node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
Arguments.of("true", true),
Arguments.of("false", false) List<AttributeKvEntry> currentAttributes = List.of(
new BaseAttributeKvEntry(123L, new StringDataEntry("address", "Prospect Beresteiskyi 1")),
new BaseAttributeKvEntry(123L, new BooleanDataEntry("valid", true)),
new BaseAttributeKvEntry(123L, new LongDataEntry("counter", 100L))
); );
given(attributesServiceMock.find(eq(tenantId), eq(deviceId), eq(AttributeScope.valueOf(config.getScope())), anyList())).willReturn(immediateFuture(currentAttributes));
var data = JacksonUtil.newObjectNode()
.put("address", "Prospect Beresteiskyi 1")
.put("valid", true)
.put("counter", 100L);
var msg = TbMsg.newMsg()
.type(TbMsgType.POST_ATTRIBUTES_REQUEST)
.originator(deviceId)
.data(data.toString())
.metaData(TbMsgMetaData.EMPTY)
.build();
// WHEN
node.onMsg(ctxMock, msg);
// THEN
then(telemetryServiceMock).shouldHaveNoInteractions();
then(ctxMock).should().tellSuccess(msg);
} }
// Notify device backward-compatibility test // Notify device backward-compatibility test
@ParameterizedTest @ParameterizedTest
@MethodSource @MethodSource
void givenNotifyDeviceMdValue_whenSaveAndNotify_thenVerifyExpectedArgumentForNotifyDeviceInSaveAndNotifyMethod(String mdValue, boolean expectedArgumentValue) throws TbNodeException { void givenVariousValuesForNotifyDeviceInMetadata_thenShouldCorrectlyParseValueFromMetadata(String mdValue, boolean expectedArgumentValue) throws TbNodeException {
var ctxMock = mock(TbContext.class); // GIVEN
var telemetryServiceMock = mock(RuleEngineTelemetryService.class); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
ObjectNode defaultConfig = (ObjectNode) JacksonUtil.valueToTree(new TbMsgAttributesNodeConfiguration().defaultConfiguration());
defaultConfig.put("notifyDevice", false); given(attributesServiceMock.find(tenantId, deviceId, AttributeScope.valueOf(config.getScope()), List.of("mode"))).willReturn(
var tbNodeConfiguration = new TbNodeConfiguration(defaultConfig); immediateFuture(List.of(new BaseAttributeKvEntry(123L, new StringDataEntry("mode", "tilt"))))
);
assertThat(defaultConfig.has("notifyDevice")).as("pre condition has notifyDevice").isTrue();
var metadata = new TbMsgMetaData();
when(ctxMock.getTenantId()).thenReturn(tenantId); metadata.putValue(NOTIFY_DEVICE_METADATA_KEY, mdValue);
when(ctxMock.getTelemetryService()).thenReturn(telemetryServiceMock);
willCallRealMethod().given(node).init(any(TbContext.class), any(TbNodeConfiguration.class)); var msg = TbMsg.newMsg()
willCallRealMethod().given(node).saveAttr(any(), eq(ctxMock), any(TbMsg.class), any(AttributeScope.class), anyBoolean()); .type(TbMsgType.POST_ATTRIBUTES_REQUEST)
node.init(ctxMock, tbNodeConfiguration);
TbMsgMetaData md = new TbMsgMetaData();
if (mdValue != null) {
md.putValue(NOTIFY_DEVICE_METADATA_KEY, mdValue);
}
// dummy list with one ts kv to pass the empty list check.
var testTbMsg = TbMsg.newMsg()
.type(TbMsgType.POST_TELEMETRY_REQUEST)
.originator(deviceId) .originator(deviceId)
.copyMetaData(md) .data(JacksonUtil.newObjectNode().put("mode", "vibration").toString())
.data(TbMsg.EMPTY_STRING) .metaData(metadata)
.build(); .build();
List<AttributeKvEntry> testAttrList = List.of(new BaseAttributeKvEntry(0L, new StringDataEntry("testKey", "testValue")));
node.saveAttr(testAttrList, ctxMock, testTbMsg, AttributeScope.SHARED_SCOPE, false); // WHEN
node.onMsg(ctxMock, msg);
// THEN
then(telemetryServiceMock).should().saveAttributes(assertArg(request -> assertThat(request.isNotifyDevice()).isEqualTo(expectedArgumentValue)));
}
verify(telemetryServiceMock, times(1)).saveAttributes(assertArg(request -> { // Notify device backward-compatibility test arguments
assertThat(request.getTenantId()).isEqualTo(tenantId); static Stream<Arguments> givenVariousValuesForNotifyDeviceInMetadata_thenShouldCorrectlyParseValueFromMetadata() {
assertThat(request.getEntityId()).isEqualTo(deviceId); return Stream.of(
assertThat(request.getScope()).isEqualTo(AttributeScope.SHARED_SCOPE); Arguments.of(null, true),
assertThat(request.getEntries()).isEqualTo(testAttrList); Arguments.of("null", false),
assertThat(request.isNotifyDevice()).isEqualTo(expectedArgumentValue); Arguments.of("true", true),
})); Arguments.of("false", false)
);
} }
// Rule nodes upgrade // Rule nodes upgrade
private static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { static Stream<Arguments> givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() {
return Stream.of( return Stream.of(
// default config for version 0 // default config for version 0
Arguments.of(0, Arguments.of(0,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":\"false\"}", """
{
"scope": "CLIENT_SCOPE",
"notifyDevice": "false",
"sendAttributesUpdatedNotification": "false"
}
""",
true, true,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":false}"), """
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": false
}
"""
),
// default config for version 1 with upgrade from version 0 // default config for version 1 with upgrade from version 0
Arguments.of(0, Arguments.of(0,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", """
false, {
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), "scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
""",
true,
"""
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
),
// all flags are booleans // all flags are booleans
Arguments.of(1, Arguments.of(1,
"{\"scope\":\"SHARED_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", """
false, {
"{\"scope\":\"SHARED_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), "scope": "SHARED_SCOPE",
"notifyDevice": true,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
""",
true,
"""
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "SHARED_SCOPE",
"notifyDevice": true,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
),
// no boolean flags set // no boolean flags set
Arguments.of(1, Arguments.of(1,
"{\"scope\":\"CLIENT_SCOPE\"}", """
{
"scope": "CLIENT_SCOPE"
}
""",
true, true,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), """
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE",
"notifyDevice": true,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
),
// all flags are boolean strings // all flags are boolean strings
Arguments.of(1, Arguments.of(1,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":\"false\",\"updateAttributesOnlyOnValueChange\":\"true\"}", """
{
"scope": "CLIENT_SCOPE",
"notifyDevice": "false",
"sendAttributesUpdatedNotification": "false",
"updateAttributesOnlyOnValueChange": "true"
}
""",
true, true,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), """
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
),
// at least one flag is boolean string // at least one flag is boolean string
Arguments.of(1, Arguments.of(1,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"false\",\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", """
{
"scope": "CLIENT_SCOPE",
"notifyDevice": "false",
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
""",
true, true,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":false,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}"), """
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
),
// notify device flag is null // notify device flag is null
Arguments.of(1, Arguments.of(1,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":\"null\",\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}", """
{
"scope": "CLIENT_SCOPE",
"notifyDevice": "null",
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
""",
true,
"""
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "CLIENT_SCOPE",
"notifyDevice": true,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
),
// default config for version 2
Arguments.of(2,
"""
{
"scope": "SERVER_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
""",
true, true,
"{\"scope\":\"CLIENT_SCOPE\",\"notifyDevice\":true,\"sendAttributesUpdatedNotification\":false,\"updateAttributesOnlyOnValueChange\":true}") """
{
"processingSettings": {
"type": "ON_EVERY_MESSAGE"
},
"scope": "SERVER_SCOPE",
"notifyDevice": false,
"sendAttributesUpdatedNotification": false,
"updateAttributesOnlyOnValueChange": true
}
"""
)
); );
} }

25
rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNodeTest.java

@ -73,6 +73,10 @@ import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Advanced;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.Deduplicate;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.OnEveryMessage;
import static org.thingsboard.rule.engine.telemetry.settings.TimeseriesProcessingSettings.WebSocketsOnly;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest { public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@ -110,7 +114,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void verifyDefaultConfig() { public void verifyDefaultConfig() {
assertThat(config.getDefaultTTL()).isEqualTo(0L); assertThat(config.getDefaultTTL()).isEqualTo(0L);
assertThat(config.getProcessingSettings()).isInstanceOf(TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage.class); assertThat(config.getProcessingSettings()).isInstanceOf(OnEveryMessage.class);
assertThat(config.isUseServerTs()).isFalse(); assertThat(config.isUseServerTs()).isFalse();
} }
@ -220,11 +224,11 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
// GIVEN // GIVEN
config.setDefaultTTL(10L); config.setDefaultTTL(10L);
var timeseriesStrategy = ProcessingStrategy.onEveryMessage(); var timeseries = ProcessingStrategy.onEveryMessage();
var latestStrategy = ProcessingStrategy.skip(); var latest = ProcessingStrategy.skip();
var webSockets = ProcessingStrategy.onEveryMessage(); var webSockets = ProcessingStrategy.onEveryMessage();
var calculatedFields = ProcessingStrategy.onEveryMessage(); var calculatedFields = ProcessingStrategy.onEveryMessage();
var processingSettings = new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced(timeseriesStrategy, latestStrategy, webSockets, calculatedFields); var processingSettings = new Advanced(timeseries, latest, webSockets, calculatedFields);
config.setProcessingSettings(processingSettings); config.setProcessingSettings(processingSettings);
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
@ -336,7 +340,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { public void givenOnEveryMessageProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException {
// GIVEN // GIVEN
config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.OnEveryMessage()); config.setProcessingSettings(new OnEveryMessage());
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
@ -374,7 +378,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException { public void givenDeduplicateProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenPersistThisMessageOnlyFirstTime() throws TbNodeException {
// GIVEN // GIVEN
config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Deduplicate(10)); config.setProcessingSettings(new Deduplicate(10));
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
@ -412,7 +416,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException { public void givenWebSocketsOnlyProcessingSettingsAndSameMessageTwoTimes_whenOnMsg_thenSendsOnlyWsUpdateTwoTimes() throws TbNodeException {
// GIVEN // GIVEN
config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.WebSocketsOnly()); config.setProcessingSettings(new WebSocketsOnly());
node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config)));
@ -450,7 +454,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException { public void givenAdvancedProcessingSettingsWithOnEveryMessageStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenPersistSameMessageTwoTimes() throws TbNodeException {
// GIVEN // GIVEN
config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( config.setProcessingSettings(new Advanced(
ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage(),
ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage(),
ProcessingStrategy.onEveryMessage(), ProcessingStrategy.onEveryMessage(),
@ -477,7 +481,6 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
.previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds()) .previousCalculatedFieldIds(msg.getPreviousCalculatedFieldIds())
.tbMsgId(msg.getId()) .tbMsgId(msg.getId())
.tbMsgType(msg.getInternalType()) .tbMsgType(msg.getInternalType())
.callback(new TelemetryNodeCallback(ctxMock, msg))
.build(); .build();
node.onMsg(ctxMock, msg); node.onMsg(ctxMock, msg);
@ -494,7 +497,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException { public void givenAdvancedProcessingSettingsWithDifferentDeduplicateStrategyForEachAction_whenOnMsg_thenEvaluatesStrategiesForEachActionsIndependently() throws TbNodeException {
// GIVEN // GIVEN
config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( config.setProcessingSettings(new Advanced(
ProcessingStrategy.deduplicate(1), ProcessingStrategy.deduplicate(1),
ProcessingStrategy.deduplicate(2), ProcessingStrategy.deduplicate(2),
ProcessingStrategy.deduplicate(3), ProcessingStrategy.deduplicate(3),
@ -580,7 +583,7 @@ public class TbMsgTimeseriesNodeTest extends AbstractRuleNodeUpgradeTest {
@Test @Test
public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException { public void givenAdvancedProcessingSettingsWithSkipStrategiesForAllActionsAndSameMessageTwoTimes_whenOnMsg_thenSkipsSameMessageTwoTimes() throws TbNodeException {
// GIVEN // GIVEN
config.setProcessingSettings(new TbMsgTimeseriesNodeConfiguration.ProcessingSettings.Advanced( config.setProcessingSettings(new Advanced(
ProcessingStrategy.skip(), ProcessingStrategy.skip(),
ProcessingStrategy.skip(), ProcessingStrategy.skip(),
ProcessingStrategy.skip(), ProcessingStrategy.skip(),

14
ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html

@ -18,22 +18,26 @@
<section [formGroup]="processingForm" class="tb-form-panel no-border no-padding"> <section [formGroup]="processingForm" class="tb-form-panel no-border no-padding">
<tb-example-hint <tb-example-hint
[hintText]="'rule-node-config.save-time-series.advanced-settings-hint'" [hintText]="'rule-node-config.save-time-series.advanced-settings-hint'"
[popupHelpLink]="'rulenode/save_timeseries_node_advanced'" [popupHelpLink]="timeseries ? 'rulenode/save_timeseries_node_advanced' : 'rulenode/save_attributes_node_advanced'"
> >
</tb-example-hint> </tb-example-hint>
<tb-advanced-processing-setting-row <tb-advanced-processing-setting-row *ngIf="timeseries"
formControlName="timeseries" formControlName="timeseries"
title="{{ 'rule-node-config.save-time-series.time-series' | translate }}" title="{{ 'rule-node-config.save-time-series.time-series' | translate }}"
></tb-advanced-processing-setting-row> ></tb-advanced-processing-setting-row>
<tb-advanced-processing-setting-row <tb-advanced-processing-setting-row *ngIf="attributes"
formControlName="attributes"
title="{{ 'rule-node-config.save-attribute.attributes' | translate }}"
></tb-advanced-processing-setting-row>
<tb-advanced-processing-setting-row *ngIf="latest"
formControlName="latest" formControlName="latest"
title="{{ 'rule-node-config.save-time-series.latest' | translate }}" title="{{ 'rule-node-config.save-time-series.latest' | translate }}"
></tb-advanced-processing-setting-row> ></tb-advanced-processing-setting-row>
<tb-advanced-processing-setting-row <tb-advanced-processing-setting-row *ngIf="webSockets"
formControlName="webSockets" formControlName="webSockets"
title="{{ 'rule-node-config.save-time-series.web-sockets' | translate }}" title="{{ 'rule-node-config.save-time-series.web-sockets' | translate }}"
></tb-advanced-processing-setting-row> ></tb-advanced-processing-setting-row>
<tb-advanced-processing-setting-row <tb-advanced-processing-setting-row *ngIf="calculatedFields"
formControlName="calculatedFields" formControlName="calculatedFields"
title="{{ 'rule-node-config.save-time-series.calculated-fields' | translate }}" title="{{ 'rule-node-config.save-time-series.calculated-fields' | translate }}"
></tb-advanced-processing-setting-row> ></tb-advanced-processing-setting-row>

60
ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts

@ -19,12 +19,15 @@ import {
FormBuilder, FormBuilder,
NG_VALIDATORS, NG_VALIDATORS,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
UntypedFormGroup,
ValidationErrors, ValidationErrors,
Validator Validator
} from '@angular/forms'; } from '@angular/forms';
import { Component, forwardRef } from '@angular/core'; import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/timeseries-config.models'; import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/timeseries-config.models';
import { coerceBoolean } from '@shared/decorators/coercion';
import { AttributeAdvancedProcessingStrategy } from '@home/components/rule-node/action/attributes-config.model';
@Component({ @Component({
selector: 'tb-advanced-processing-settings', selector: 'tb-advanced-processing-settings',
@ -39,20 +42,55 @@ import { AdvancedProcessingStrategy } from '@home/components/rule-node/action/ti
multi: true multi: true
}] }]
}) })
export class AdvancedProcessingSettingComponent implements ControlValueAccessor, Validator { export class AdvancedProcessingSettingComponent implements OnInit, ControlValueAccessor, Validator {
processingForm = this.fb.group({ @Input()
timeseries: [null], @coerceBoolean()
latest: [null], timeseries = false;
webSockets: [null],
calculatedFields: [null] @Input()
}); @coerceBoolean()
attributes = false;
@Input()
@coerceBoolean()
latest = false;
@Input()
@coerceBoolean()
webSockets = false;
@Input()
@coerceBoolean()
calculatedFields = false;
processingForm: UntypedFormGroup;
private propagateChange: (value: any) => void = () => {}; private propagateChange: (value: any) => void = () => {};
constructor(private fb: FormBuilder) { constructor(private fb: FormBuilder,
private destroyRef: DestroyRef) {
}
ngOnInit() {
this.processingForm = this.fb.group({});
if (this.timeseries) {
this.processingForm.addControl('timeseries', this.fb.control(null, []));
}
if (this.attributes) {
this.processingForm.addControl('attributes', this.fb.control(null, []));
}
if (this.latest) {
this.processingForm.addControl('latest', this.fb.control(null, []));
}
if (this.webSockets) {
this.processingForm.addControl('webSockets', this.fb.control(null, []));
}
if (this.calculatedFields) {
this.processingForm.addControl('calculatedFields', this.fb.control(null, []));
}
this.processingForm.valueChanges.pipe( this.processingForm.valueChanges.pipe(
takeUntilDestroyed() takeUntilDestroyed(this.destroyRef)
).subscribe(value => this.propagateChange(value)); ).subscribe(value => this.propagateChange(value));
} }
@ -77,7 +115,7 @@ export class AdvancedProcessingSettingComponent implements ControlValueAccessor,
}; };
} }
writeValue(value: AdvancedProcessingStrategy) { writeValue(value: AdvancedProcessingStrategy | AttributeAdvancedProcessingStrategy) {
this.processingForm.patchValue(value, {emitEvent: false}); this.processingForm.patchValue(value, {emitEvent: false});
} }
} }

51
ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html

@ -16,11 +16,54 @@
--> -->
<section [formGroup]="attributesConfigForm" class="tb-form-panel no-border no-padding"> <section [formGroup]="attributesConfigForm" class="tb-form-panel no-border no-padding">
<div class="tb-form-panel stroked no-padding-bottom no-gap" formGroupName="processingSettings">
<div class="mb-4 flex flex-row items-center justify-between">
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'rule-node-config.save-attribute.processing-settings-hint' | translate}}" translate>
rule-node-config.save-attribute.processing-settings
</div>
<tb-toggle-select appearance="fill" selectMediaBreakpoint="xs"
formControlName="isAdvanced">
<tb-toggle-option [value]=false>{{ 'rule-node-config.basic-mode' | translate}}</tb-toggle-option>
<tb-toggle-option [value]=true>{{ 'rule-node-config.advanced-mode' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
@if(!attributesConfigForm.get('processingSettings.isAdvanced').value) {
<mat-form-field>
<mat-label translate>rule-node-config.save-attribute.strategy</mat-label>
<mat-select formControlName="type">
@for (strategy of processingStrategies; track strategy) {
<mat-option [value]="strategy">{{ ProcessingTypeTranslationMap.get(strategy) | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
@if(attributesConfigForm.get('processingSettings.type').value === ProcessingType.DEDUPLICATE) {
<tb-time-unit-input
required
labelText="{{ 'rule-node-config.save-attribute.deduplication-interval' | translate }}"
requiredText="{{ 'rule-node-config.save-attribute.deduplication-interval-required' | translate }}"
minErrorText="{{ 'rule-node-config.save-attribute.deduplication-interval-min-max-range' | translate }}"
maxErrorText="{{ 'rule-node-config.save-attribute.deduplication-interval-min-max-range' | translate }}"
[maxTime]="maxDeduplicateTime"
[minTime]="1"
formControlName="deduplicationIntervalSecs">
</tb-time-unit-input>
}
} @else {
<tb-advanced-processing-settings
class="mb-4"
formControlName="advanced"
attributes webSockets calculatedFields
></tb-advanced-processing-settings>
}
</div>
<div class="tb-form-panel stroked"> <div class="tb-form-panel stroked">
<tb-example-hint [hintText]="'rule-node-config.attributes-scope-hint'"> <div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'rule-node-config.attributes-scope-hint' | translate}}" translate>
</tb-example-hint> rule-node-config.save-attribute.scope
</div>
<div class="tb-form-row no-border no-padding tb-standard-fields"> <div class="tb-form-row no-border no-padding tb-standard-fields">
<mat-form-field class="flex"> <mat-form-field class="flex" subscriptSizing="dynamic">
<mat-label>{{ 'rule-node-config.attributes-scope' | translate }}</mat-label> <mat-label>{{ 'rule-node-config.attributes-scope' | translate }}</mat-label>
<mat-select required <mat-select required
class="tb-entity-type-select" matInput formControlName="scope"> class="tb-entity-type-select" matInput formControlName="scope">
@ -29,7 +72,7 @@
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field class="flex"> <mat-form-field class="flex" subscriptSizing="dynamic">
<mat-label>{{ 'rule-node-config.attributes-scope-value' | translate }}</mat-label> <mat-label>{{ 'rule-node-config.attributes-scope-value' | translate }}</mat-label>
<input type="text" matInput readonly disabled [ngModel]="attributesConfigForm.get('scope').value" [ngModelOptions]="{standalone: true}"> <input type="text" matInput readonly disabled [ngModel]="attributesConfigForm.get('scope').value" [ngModelOptions]="{standalone: true}">
<button type="button" <button type="button"

91
ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts

@ -15,10 +15,22 @@
/// ///
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { FormGroup, UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models';
import { AttributeScope, telemetryTypeTranslations } from '@app/shared/models/telemetry/telemetry.models'; import { AttributeScope, telemetryTypeTranslations } from '@app/shared/models/telemetry/telemetry.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import {
maxDeduplicateTimeSecs,
ProcessingSettings,
ProcessingSettingsForm,
ProcessingType,
ProcessingTypeTranslationMap
} from '@home/components/rule-node/action/timeseries-config.models';
import {
AttributeNodeConfiguration,
AttributeNodeConfigurationForm,
defaultAttributeAdvancedProcessingStrategy
} from '@home/components/rule-node/action/attributes-config.model';
@Component({ @Component({
selector: 'tb-action-node-attributes-config', selector: 'tb-action-node-attributes-config',
@ -31,6 +43,12 @@ export class AttributesConfigComponent extends RuleNodeConfigurationComponent {
attributeScopes = Object.keys(AttributeScope); attributeScopes = Object.keys(AttributeScope);
telemetryTypeTranslationsMap = telemetryTypeTranslations; telemetryTypeTranslationsMap = telemetryTypeTranslations;
ProcessingType = ProcessingType;
processingStrategies = [ProcessingType.ON_EVERY_MESSAGE, ProcessingType.DEDUPLICATE, ProcessingType.WEBSOCKETS_ONLY];
ProcessingTypeTranslationMap = ProcessingTypeTranslationMap;
maxDeduplicateTime = maxDeduplicateTimeSecs;
attributesConfigForm: UntypedFormGroup; attributesConfigForm: UntypedFormGroup;
constructor(private fb: UntypedFormBuilder) { constructor(private fb: UntypedFormBuilder) {
@ -41,8 +59,64 @@ export class AttributesConfigComponent extends RuleNodeConfigurationComponent {
return this.attributesConfigForm; return this.attributesConfigForm;
} }
protected validatorTriggers(): string[] {
return ['processingSettings.isAdvanced', 'processingSettings.type'];
}
protected prepareInputConfig(config: AttributeNodeConfiguration): AttributeNodeConfigurationForm {
let processingSettings: ProcessingSettingsForm;
if (config?.processingSettings) {
const isAdvanced = config?.processingSettings?.type === ProcessingType.ADVANCED;
processingSettings = {
type: isAdvanced ? ProcessingType.ON_EVERY_MESSAGE : config.processingSettings.type,
isAdvanced: isAdvanced,
deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs ?? 60,
advanced: isAdvanced ? config.processingSettings : defaultAttributeAdvancedProcessingStrategy
}
} else {
processingSettings = {
type: ProcessingType.ON_EVERY_MESSAGE,
isAdvanced: false,
deduplicationIntervalSecs: 60,
advanced: defaultAttributeAdvancedProcessingStrategy
};
}
return {
...config,
processingSettings: processingSettings
}
}
protected prepareOutputConfig(config: AttributeNodeConfigurationForm): AttributeNodeConfiguration {
let processingSettings: ProcessingSettings;
if (config.processingSettings.isAdvanced) {
processingSettings = {
...config.processingSettings.advanced,
type: ProcessingType.ADVANCED
};
} else {
processingSettings = {
type: config.processingSettings.type,
deduplicationIntervalSecs: config.processingSettings?.deduplicationIntervalSecs
};
}
return {
...config,
processingSettings
};
}
protected onConfigurationSet(configuration: RuleNodeConfiguration) { protected onConfigurationSet(configuration: RuleNodeConfiguration) {
this.attributesConfigForm = this.fb.group({ this.attributesConfigForm = this.fb.group({
processingSettings: this.fb.group({
isAdvanced: [configuration?.processingSettings?.isAdvanced ?? false],
type: [configuration?.processingSettings?.type ?? ProcessingType.ON_EVERY_MESSAGE],
deduplicationIntervalSecs: [
{value: configuration?.processingSettings?.deduplicationIntervalSecs ?? 60, disabled: true},
[Validators.required, Validators.max(maxDeduplicateTimeSecs)]
],
advanced: [{value: null, disabled: true}]
}),
scope: [configuration ? configuration.scope : null, [Validators.required]], scope: [configuration ? configuration.scope : null, [Validators.required]],
notifyDevice: [configuration ? configuration.notifyDevice : true, []], notifyDevice: [configuration ? configuration.notifyDevice : true, []],
sendAttributesUpdatedNotification: [configuration ? configuration.sendAttributesUpdatedNotification : false, []], sendAttributesUpdatedNotification: [configuration ? configuration.sendAttributesUpdatedNotification : false, []],
@ -62,4 +136,19 @@ export class AttributesConfigComponent extends RuleNodeConfigurationComponent {
}); });
} }
protected updateValidators(emitEvent: boolean, _trigger?: string) {
const processingForm = this.attributesConfigForm.get('processingSettings') as FormGroup;
const isAdvanced: boolean = processingForm.get('isAdvanced').value;
const type: ProcessingType = processingForm.get('type').value;
if (!isAdvanced && type === ProcessingType.DEDUPLICATE) {
processingForm.get('deduplicationIntervalSecs').enable({emitEvent});
} else {
processingForm.get('deduplicationIntervalSecs').disable({emitEvent});
}
if (isAdvanced) {
processingForm.get('advanced').enable({emitEvent});
} else {
processingForm.get('advanced').disable({emitEvent});
}
}
} }

60
ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.model.ts

@ -0,0 +1,60 @@
///
/// Copyright © 2016-2025 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { AttributeScope } from '@shared/models/telemetry/telemetry.models';
import { BasicProcessingSettings, ProcessingType } from '@home/components/rule-node/action/timeseries-config.models';
export interface AttributeNodeConfiguration {
processingSettings: AttributeProcessingSettings;
scope: AttributeScope;
notifyDevice: boolean;
sendAttributesUpdatedNotification: boolean;
updateAttributesOnlyOnValueChange: boolean;
}
export interface AttributeNodeConfigurationForm extends Omit<AttributeNodeConfiguration, 'processingSettings'> {
processingSettings: AttributeProcessingSettingsForm
}
export type AttributeProcessingSettings = BasicProcessingSettings & Partial<AttributeDeduplicateProcessingStrategy> & Partial<AttributeAdvancedProcessingStrategy>;
export type AttributeProcessingSettingsForm = Omit<AttributeProcessingSettings, keyof AttributeAdvancedProcessingStrategy> & {
isAdvanced: boolean;
advanced?: Partial<AttributeAdvancedProcessingStrategy>;
type: ProcessingType;
};
export interface AttributeDeduplicateProcessingStrategy extends BasicProcessingSettings {
deduplicationIntervalSecs: number;
}
export interface AttributeAdvancedProcessingStrategy extends BasicProcessingSettings {
attributes: AttributeAdvancedProcessingConfig;
webSockets: AttributeAdvancedProcessingConfig;
calculatedFields: AttributeAdvancedProcessingConfig;
}
export type AttributeAdvancedProcessingConfig = WithOptional<AttributeDeduplicateProcessingStrategy, 'deduplicationIntervalSecs'>;
export const defaultAdvancedProcessingConfig: AttributeAdvancedProcessingConfig = {
type: ProcessingType.ON_EVERY_MESSAGE
}
export const defaultAttributeAdvancedProcessingStrategy: Omit<AttributeAdvancedProcessingStrategy, 'type'> = {
attributes: defaultAdvancedProcessingConfig,
webSockets: defaultAdvancedProcessingConfig,
calculatedFields: defaultAdvancedProcessingConfig,
}

1
ui-ngx/src/app/modules/home/components/rule-node/action/timeseries-config.component.html

@ -53,6 +53,7 @@
<tb-advanced-processing-settings <tb-advanced-processing-settings
class="mb-4" class="mb-4"
formControlName="advanced" formControlName="advanced"
timeseries latest webSockets calculatedFields
></tb-advanced-processing-settings> ></tb-advanced-processing-settings>
} }
</div> </div>

32
ui-ngx/src/assets/help/en_US/rulenode/save_attributes_node_advanced.md

@ -0,0 +1,32 @@
#### Potential unexpected behavior with mixed processing strategies
When configuring the processing strategies, certain combinations can lead to unexpected behavior. Consider the following scenarios:
- **Skipping database storage**
Choosing to disable attribute persistence introduces the risk of having only partial data available.
For example, if a message is processed solely for real-time notifications via WebSockets and not stored in the database, then attribute queries might not reflect the data visible on the dashboard.
- **Disabling WebSocket (WS) updates**
If WS updates are disabled, any changes to the attribute data won’t be pushed to dashboards (or other WS subscriptions).
This means that even if a database is updated, dashboards may not display the updated data until browser page is reloaded.
- **Skipping calculated field recalculation**
If attribute data is saved to the database while bypassing calculated field recalculation, the aggregated value may not update to reflect the saved data.
Conversely, if the calculated field is recalculated with new data but the corresponding attribute value is not persisted in the database, the calculated field's value might include data that isn’t stored.
- **Different deduplication intervals across actions**
When you configure different deduplication intervals for actions, the same incoming message might be processed differently for each action.
For example, a message might be stored immediately in the Attributes table (if set to *On every message*) while not being present on a dashboard because its deduplication interval hasn’t elapsed.
- **Deduplication cache clearing**
The deduplication mechanism uses a cache to track processed messages within each interval.
For performance and system stability reasons, this cache is periodically cleared.
As a result, if a cache entry is removed during the deduplication period, messages from the same originator may be processed more than once within that interval.
This means deduplication should be used as a performance optimization rather than an absolute guarantee of single processing per interval.
We recommend using deduplication only when the occasional repeated processing is acceptable and won't cause system correctness issue or data inconsistencies.

17
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -5264,6 +5264,23 @@
"web-sockets": "WebSockets", "web-sockets": "WebSockets",
"calculated-fields": "Calculated fields" "calculated-fields": "Calculated fields"
}, },
"save-attribute": {
"processing-settings": "Processing settings",
"processing-settings-hint": "Define how incoming messages are processed. In Basic mode, select a preconfigured processing strategy or enable only WebSocket updates. Advanced mode allows you to select individual processing strategies for each action.",
"advanced-settings-hint": "Be cautious when configuring processing strategies. Certain combinations can lead to unexpected behavior.",
"strategy": "Strategy",
"deduplication-interval": "Deduplication interval",
"deduplication-interval-required": "Deduplication interval is required",
"deduplication-interval-min-max-range": "Deduplication interval should be at least 1 second and at most 1 day",
"scope": "Scope",
"strategy-type": {
"every-message": "On every message",
"skip": "Skip",
"deduplicate": "Deduplicate",
"web-sockets-only": "WebSockets only"
},
"attributes": "Attributes"
},
"key-val": { "key-val": {
"key": "Key", "key": "Key",
"value": "Value", "value": "Value",

Loading…
Cancel
Save