diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 054b37994a..b8c49441fe 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -46,6 +46,12 @@ SET profile_data = jsonb_set( WHEN (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' THEN NULL ELSE to_jsonb(60) + END, + 'minAllowedAggregationIntervalInSecForCF', + CASE + WHEN (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF' + THEN NULL + ELSE to_jsonb(60) END ) ), @@ -59,6 +65,8 @@ WHERE NOT ( (profile_data -> 'configuration') ? 'maxRelatedEntitiesToReturnPerCfArgument' AND (profile_data -> 'configuration') ? 'minAllowedDeduplicationIntervalInSecForCF' + AND + (profile_data -> 'configuration') ? 'minAllowedAggregationIntervalInSecForCF' ); -- UPDATE TENANT PROFILE CONFIGURATION END diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 35cf9cb467..16081a6d31 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -97,8 +97,8 @@ import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.queue.QueueService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.resource.ResourceService; +import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.rule.RuleNodeStateService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; @@ -664,6 +664,10 @@ public class ActorSystemContext { @Getter private long cfCalculationResultTimeout; + @Value("${actors.calculated_fields.check_interval:60}") + @Getter + private long cfCheckInterval; + @Value("${actors.alarms.reevaluation_interval:120}") @Getter private long alarmRulesReevaluationInterval; @@ -851,7 +855,7 @@ public class ActorSystemContext { if (arguments != null) { eventBuilder.arguments(JacksonUtil.toString( arguments.entrySet().stream() - .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().toTbelCfArg())) + .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().jsonValue())) )); } if (result != null) { diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 20cacda26a..da16e55db8 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -32,6 +32,7 @@ import org.thingsboard.server.actors.tenant.TenantActor; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.MsgType; @@ -165,6 +166,18 @@ public class AppActor extends ContextAwareActor { private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) { TbActorRef target = null; if (TenantId.SYS_TENANT_ID.equals(msg.getTenantId())) { + if (systemContext.isTenantComponentsInitEnabled()) { + if (msg.getEntityId() instanceof TenantProfileId tenantProfileId) { + tenantService.findTenantIdsByTenantProfileId(tenantProfileId).forEach(tenantId -> { + getOrCreateTenantActor(tenantId).ifPresentOrElse(tenantActor -> { + log.debug("[{}] Sending component lifecycle msg for tenant.", tenantId); + tenantActor.tellWithHighPriority(msg); + }, () -> { + log.debug("Ignoring component lifecycle msg for tenant {} because it is not managed by this service", tenantId); + }); + }); + } + } if (!msg.getEntityId().getEntityType().isOneOf(EntityType.TENANT_PROFILE, EntityType.TB_RESOURCE)) { log.warn("Message has system tenant id: {}", msg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 72e7fa4b9f..8a6b2cc899 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -123,9 +123,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state != null) { state.setCtx(msg.getCtx(), actorCtx); state.setPartition(msg.getPartition()); - if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) { - relatedEntitiesAggState.scheduleReevaluation(); - } states.put(cfId, state); } else { removeState(cfId); @@ -136,7 +133,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM log.debug("Processing CF state partition restore msg: {}", msg); for (CalculatedFieldState state : states.values()) { if (msg.getPartition().equals(state.getPartition())) { - state.init(); + state.init(true); } } } @@ -451,7 +448,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) { state.setCtx(ctx, actorCtx); - state.init(); + state.init(false); if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isRelationQueryDynamicArguments()) { GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state; @@ -500,7 +497,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } else { if (DebugModeUtil.isDebugFailuresAvailable(ctx.getCalculatedField())) { String errorMsg = ctx.isInitialized() ? state.getReadinessStatus().errorMsg() : "Calculated field state is not initialized!"; - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg); + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, null, errorMsg); } callback.onSuccess(); } diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index ccdf719f6c..9dc1342b23 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -71,6 +71,7 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -83,6 +84,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Function; +import java.util.stream.Stream; import static org.thingsboard.server.utils.CalculatedFieldUtils.fromProto; @@ -187,7 +189,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } catch (Exception e) { log.warn("[{}] Failed to trigger CFs reevaluation", tenantId, e); } - }, systemContext.getAlarmRulesReevaluationInterval(), systemContext.getAlarmRulesReevaluationInterval(), TimeUnit.SECONDS); + }, systemContext.getCfCheckInterval(), systemContext.getCfCheckInterval(), TimeUnit.SECONDS); } public void onEntityLifecycleMsg(CalculatedFieldEntityLifecycleMsg msg) throws CalculatedFieldException { @@ -222,6 +224,12 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware default -> msg.getCallback().onSuccess(); } } + case TENANT_PROFILE -> { + switch (event) { + case UPDATED -> onTenantProfileUpdated(msg.getData(), msg.getCallback()); + default -> msg.getCallback().onSuccess(); + } + } default -> msg.getCallback().onSuccess(); } } @@ -247,6 +255,14 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware callback.onSuccess(); } + private void onTenantProfileUpdated(ComponentLifecycleMsg msg, TbCallback callback) { + Stream.concat( + calculatedFields.values().stream(), + entityIdCalculatedFields.values().stream().flatMap(Collection::stream) + ).forEach(CalculatedFieldCtx::updateTenantProfileProperties); + callback.onSuccess(); + } + private void onEntityCreated(ComponentLifecycleMsg msg, TbCallback callback) { EntityId entityId = msg.getEntityId(); EntityId profileId = getProfileId(tenantId, entityId); diff --git a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java index 35a7f01b2e..11a8651026 100644 --- a/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/tenant/TenantActor.java @@ -350,7 +350,7 @@ public class TenantActor extends RuleChainManagerActor { } } if (cfActor != null) { - if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER)) { + if (msg.getEntityId().getEntityType().isOneOf(EntityType.CALCULATED_FIELD, EntityType.DEVICE, EntityType.ASSET, EntityType.CUSTOMER, EntityType.TENANT_PROFILE)) { cfActor.tellWithHighPriority(new CalculatedFieldEntityLifecycleMsg(tenantId, msg)); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index 82807d0762..9c04cb92bd 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -165,6 +165,7 @@ public class SystemInfoController extends BaseController { systemParams.setMinAllowedScheduledUpdateIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedScheduledUpdateIntervalInSecForCF()); systemParams.setMaxRelationLevelPerCfArgument(tenantProfileConfiguration.getMaxRelationLevelPerCfArgument()); systemParams.setMinAllowedDeduplicationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedDeduplicationIntervalInSecForCF()); + systemParams.setMinAllowedAggregationIntervalInSecForCF(tenantProfileConfiguration.getMinAllowedAggregationIntervalInSecForCF()); systemParams.setTrendzSettings(trendzSettingsService.findTrendzSettings(currentUser.getTenantId())); } systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index 1a3bdce1f6..6c0f70e17c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -164,9 +164,14 @@ public class TenantProfileController extends BaseController { " \"warnThreshold\": 0,\n" + " \"maxCalculatedFieldsPerEntity\": 5,\n" + " \"maxArgumentsPerCF\": 10,\n" + + " \"minAllowedScheduledUpdateIntervalInSecForCF\": 60,\n" + + " \"maxRelationLevelPerCfArgument\": 10,\n" + + " \"maxRelatedEntitiesToReturnPerCfArgument\": 100,\n" + " \"maxDataPointsPerRollingArg\": 1000,\n" + " \"maxStateSizeInKBytes\": 32,\n" + " \"maxSingleValueArgumentSizeInKBytes\": 2" + + " \"minAllowedDeduplicationIntervalInSecForCF\": 60" + + " \"minAllowedAggregationIntervalInSecForCF\": 60" + " }\n" + " },\n" + " \"default\": false\n" + diff --git a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java index d44d06407c..d4d7fd8bf8 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/AbstractCalculatedFieldProcessingService.java @@ -42,7 +42,11 @@ import org.thingsboard.server.common.data.cf.configuration.OutputStrategy; import org.thingsboard.server.common.data.cf.configuration.OutputType; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesImmediateOutputStrategy; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -66,6 +70,7 @@ import org.thingsboard.server.dao.usagerecord.ApiLimitService; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.util.Collections; @@ -76,6 +81,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.concurrent.ExecutionException; +import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -87,7 +93,10 @@ import static org.thingsboard.server.dao.util.KvUtils.filterChangedAttr; import static org.thingsboard.server.dao.util.KvUtils.toTsKvEntryList; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultAttributeEntry; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformAggMetricArgument; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformAggregationArgument; import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument; +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformTsRollingArgument; @Data @Slf4j @@ -122,6 +131,7 @@ public abstract class AbstractCalculatedFieldProcessingService { case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false, ts); case SIMPLE, SCRIPT, ALARM, PROPAGATION -> getBaseCalculatedFieldArguments(ctx, entityId, ts); case RELATED_ENTITIES_AGGREGATION -> fetchRelatedEntitiesAggArguments(ctx, entityId, ts); + case ENTITY_AGGREGATION -> fetchEntityAggArguments(ctx, entityId, ts); }; if (ctx.getCfType() == PROPAGATION) { argFutures.put(PROPAGATION_CONFIG_ARGUMENT, fetchPropagationCalculatedFieldArgument(ctx, entityId)); @@ -155,19 +165,21 @@ public abstract class AbstractCalculatedFieldProcessingService { return argFutures.entrySet().stream() .collect(Collectors.toMap( Map.Entry::getKey, // Keep the key as is - entry -> { - try { - return entry.getValue().get(); - } catch (ExecutionException e) { - Throwable cause = e.getCause(); - throw new RuntimeException("Failed to fetch " + entry.getKey() + ": " + cause.getMessage(), cause); - } catch (InterruptedException e) { - throw new RuntimeException("Failed to fetch" + entry.getKey(), e); - } - } + entry -> resolveArgumentValue(entry.getKey(), entry.getValue()) )); } + protected ArgumentEntry resolveArgumentValue(String key, ListenableFuture future) { + try { + return future.get(); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + throw new RuntimeException("Failed to fetch " + key + ": " + cause.getMessage(), cause); + } catch (InterruptedException e) { + throw new RuntimeException("Failed to fetch" + key, e); + } + } + protected ListenableFuture fetchPropagationCalculatedFieldArgument(CalculatedFieldCtx ctx, EntityId entityId) { ListenableFuture> propagationEntityIds = fromDynamicSource(ctx.getTenantId(), entityId, ctx.getPropagationArgument()); return Futures.transform(propagationEntityIds, ArgumentEntry::createPropagationArgument, MoreExecutors.directExecutor()); @@ -208,6 +220,17 @@ public abstract class AbstractCalculatedFieldProcessingService { )); } + protected Map> fetchEntityAggArguments(CalculatedFieldCtx ctx, EntityId entityId, long ts) { + if (!(ctx.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration config)) { + return Collections.emptyMap(); + } + return config.getArguments().entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> fetchTimeSeries(ctx.getTenantId(), entityId, entry.getValue(), config.getInterval(), ts) + )); + } + protected ListenableFuture> resolveRelatedEntities(TenantId tenantId, EntityId entityId, RelationPathLevel relation) { Predicate filter = entityRelation -> CalculatedField.isSupportedRefEntity(entityRelation.getFrom()) && CalculatedField.isSupportedRefEntity(entityRelation.getTo()); ListenableFuture> relationsFut = relationService.findFilteredRelationsByPathQueryAsync(tenantId, new EntityRelationPathQuery(entityId, List.of(relation)), filter); @@ -307,17 +330,26 @@ public abstract class AbstractCalculatedFieldProcessingService { }; } + protected ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) { + AggFunction function = metric.getFunction(); + long intervalMs = interval.getEndTs() - interval.getStartTs(); + BaseReadTsKvQuery query = new BaseReadTsKvQuery(argKey, interval.getStartTs(), interval.getEndTs(), intervalMs, 1, Aggregation.valueOf(function.name())); + ListenableFuture argumentEntryFut = fetchTimeSeriesInternal(tenantId, entityId, query, timeSeries -> transformAggMetricArgument(timeSeries, argKey, metric)); + return resolveArgumentValue(argKey, argumentEntryFut); + } + + private ListenableFuture fetchTimeSeries(TenantId tenantId, EntityId entityId, Argument argument, AggInterval interval, long queryEndTs) { + long intervalStartTs = interval.getCurrentIntervalStartTs(); + long intervalEndTs = interval.getCurrentIntervalEndTs(); + ReadTsKvQuery query = new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), intervalStartTs, queryEndTs, 0, 1, Aggregation.NONE); + return fetchTimeSeriesInternal(tenantId, entityId, query, timeSeries -> transformAggregationArgument(timeSeries, intervalStartTs, intervalEndTs)); + } + private ListenableFuture fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument, long queryEndTs) { long argTimeWindow = argument.getTimeWindow() == 0 ? queryEndTs : argument.getTimeWindow(); long startInterval = queryEndTs - argTimeWindow; ReadTsKvQuery query = buildTsRollingQuery(tenantId, argument, startInterval, queryEndTs); - - log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query); - ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); - return Futures.transform(tsRollingFuture, tsRolling -> { - log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query); - return ArgumentEntry.createTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow); - }, calculatedFieldCallbackExecutor); + return fetchTimeSeriesInternal(tenantId, entityId, query, tsRolling -> transformTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow)); } private ListenableFuture fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) { @@ -343,6 +375,15 @@ public abstract class AbstractCalculatedFieldProcessingService { }, calculatedFieldCallbackExecutor)); } + private ListenableFuture fetchTimeSeriesInternal(TenantId tenantId, EntityId entityId, ReadTsKvQuery query, Function, ArgumentEntry> transformArgument) { + log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query); + ListenableFuture> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); + return Futures.transform(tsRollingFuture, tsRolling -> { + log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query); + return transformArgument.apply(tsRolling); + }, calculatedFieldCallbackExecutor); + } + private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) { long maxDataPoints = apiLimitService.getLimit( tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java index 27e989de70..54ddedcc42 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java @@ -17,14 +17,17 @@ package org.thingsboard.server.service.cf; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import java.util.List; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Stream; public interface CalculatedFieldCache { @@ -38,7 +41,7 @@ public interface CalculatedFieldCache { List getCalculatedFieldCtxsByEntityId(EntityId entityId); - List getAggCalculatedFieldCtxsByFilter(Predicate relatedEntityFilter); + Stream getCalculatedFieldCtxsByType(CalculatedFieldType cfType); boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter); @@ -48,6 +51,8 @@ public interface CalculatedFieldCache { void evict(CalculatedFieldId calculatedFieldId); + void handleTenantProfileUpdate(TenantProfileId tenantProfileId); + EntityId getProfileId(TenantId tenantId, EntityId entityId); Set getDynamicEntities(TenantId tenantId, EntityId entityId); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java index 858f9eb2f3..a70e9b684b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldProcessingService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.cf; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.actors.calculatedField.CalculatedFieldTelemetryMsg; import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; @@ -25,6 +26,7 @@ import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry; import java.util.List; import java.util.Map; @@ -41,6 +43,8 @@ public interface CalculatedFieldProcessingService { void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback); + ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval); + void pushMsgToLinks(CalculatedFieldTelemetryMsg msg, List linkedCalculatedFields, TbCallback callback); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 4466b368b2..f2b8d4d9db 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -33,8 +34,10 @@ import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -49,6 +52,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; +import java.util.stream.Stream; @Service @Slf4j @@ -60,6 +64,7 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { private final CalculatedFieldService calculatedFieldService; private final TbAssetProfileCache assetProfileCache; private final TbDeviceProfileCache deviceProfileCache; + private final TbTenantProfileCache tenantProfileCache; @Lazy private final ActorSystemContext systemContext; private final OwnerService ownerService; @@ -146,12 +151,10 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } @Override - public List getAggCalculatedFieldCtxsByFilter(Predicate relatedEntityFilter) { + public Stream getCalculatedFieldCtxsByType(CalculatedFieldType cfType) { return calculatedFields.values().stream() - .filter(cf -> CalculatedFieldType.RELATED_ENTITIES_AGGREGATION.equals(cf.getType())) - .map(cf -> getCalculatedFieldCtx(cf.getId())) - .filter(relatedEntityFilter) - .toList(); + .filter(cf -> cfType.equals(cf.getType())) + .map(cf -> getCalculatedFieldCtx(cf.getId())); } @Override @@ -228,6 +231,16 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { log.debug("[{}] evict calculated field links from cached links by entity id: {}", calculatedFieldId, oldCalculatedField); } + @Override + public void handleTenantProfileUpdate(TenantProfileId tenantProfileId) { + calculatedFieldsCtx.values().stream() + .filter(ctx -> { + TenantProfile tenantProfile = tenantProfileCache.get(ctx.getTenantId()); + return tenantProfile != null && tenantProfileId.equals(tenantProfile.getId()); + }) + .forEach(CalculatedFieldCtx::updateTenantProfileProperties); + } + @Override public EntityId getProfileId(TenantId tenantId, EntityId entityId) { return switch (entityId.getEntityType()) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java index 851717326f..7b5c2e42bb 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java @@ -25,6 +25,7 @@ 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.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -49,6 +50,7 @@ import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.util.ArrayList; @@ -130,6 +132,10 @@ public class DefaultCalculatedFieldProcessingService extends AbstractCalculatedF } @Override + public ArgumentEntry fetchMetricDuringInterval(TenantId tenantId, EntityId entityId, String argKey, AggMetric metric, AggIntervalEntry interval) { + return super.fetchMetricDuringInterval(tenantId, entityId, argKey, metric, interval); + } + public void processResult(TenantId tenantId, EntityId entityId, CalculatedFieldResult result, List cfIds, TbCallback callback) { if (result instanceof AlarmCalculatedFieldResult) { sendMsgToRuleEngine(tenantId, entityId, callback, result.toTbMsg(entityId, cfIds)); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java index 0236de552f..5af6b542c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java @@ -27,6 +27,7 @@ import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -188,8 +189,15 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } } - List cfCtxs = calculatedFieldCache.getAggCalculatedFieldCtxsByFilter(relatedEntityFilter); - for (CalculatedFieldCtx cfCtx : cfCtxs) { + boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter); + if (hasMatchesEntityAggCfs) { + return true; + } + + List relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) + .filter(relatedEntityFilter) + .toList(); + for (CalculatedFieldCtx cfCtx : relatedEntitiesAggregationCfs) { if (cfCtx.getCalculatedField().getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) { RelationPathLevel relation = aggConfig.getRelation(); EntitySearchDirection inverseDirection = switch (relation.direction()) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java index b331c11a47..dc23ffa979 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java @@ -18,11 +18,14 @@ package org.thingsboard.server.service.cf.ctx.state; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.JsonNode; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbelCfArg; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; @@ -39,7 +42,8 @@ import java.util.Map; @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"), @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING"), @JsonSubTypes.Type(value = PropagationArgumentEntry.class, name = "PROPAGATION"), - @JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES") + @JsonSubTypes.Type(value = RelatedEntitiesArgumentEntry.class, name = "RELATED_ENTITIES"), + @JsonSubTypes.Type(value = EntityAggregationArgumentEntry.class, name = "ENTITY_AGGREGATION") }) public interface ArgumentEntry { @@ -52,6 +56,10 @@ public interface ArgumentEntry { boolean isEmpty(); + default JsonNode jsonValue() { + return JacksonUtil.valueToTree(toTbelCfArg()); + } + TbelCfArg toTbelCfArg(); boolean isForceResetPrevious(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java index 427df2bf5b..457dd79686 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java @@ -16,5 +16,5 @@ package org.thingsboard.server.service.cf.ctx.state; public enum ArgumentEntryType { - SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION, RELATED_ENTITIES + SINGLE_VALUE, TS_ROLLING, GEOFENCING, PROPAGATION, RELATED_ENTITIES, ENTITY_AGGREGATION } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java index 20f944e433..e8174967a5 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/BaseCalculatedFieldState.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; import org.thingsboard.server.utils.CalculatedFieldUtils; import java.io.Closeable; @@ -62,7 +63,7 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, } @Override - public void init() { + public void init(boolean restored) { } @Override @@ -82,6 +83,8 @@ public abstract class BaseCalculatedFieldState implements CalculatedFieldState, validateNewEntry(key, newEntry); if (existingEntry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { relatedEntitiesArgumentEntry.updateEntry(newEntry); + } else if (existingEntry instanceof EntityAggregationArgumentEntry entityAggArgumentEntry) { + entityAggArgumentEntry.updateEntry(newEntry); } else { arguments.put(key, newEntry); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index 0df8d5ebe8..1d2e2f505a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -45,6 +45,8 @@ import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSuppor import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; @@ -57,6 +59,7 @@ import org.thingsboard.server.common.data.util.CollectionsUtil; import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldTelemetryMsgProto; +import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; @@ -93,19 +96,20 @@ public class CalculatedFieldCtx implements Closeable { private Output output; private String expression; private boolean useLatestTs; - private boolean requiresScheduledReevaluation; + + private long lastReevaluationTs; private ActorSystemContext systemContext; private TbelInvokeService tbelInvokeService; private RelationService relationService; private AlarmSubscriptionService alarmService; + private CalculatedFieldProcessingService cfProcessingService; private Map tbelExpressions; private Map> simpleExpressions; private boolean initialized; - private long maxDataPointsPerRollingArg; private long maxStateSize; private long maxSingleValueArgumentSize; @@ -191,7 +195,6 @@ public class CalculatedFieldCtx implements Closeable { if (calculatedField.getConfiguration() instanceof ScheduledUpdateSupportedCalculatedFieldConfiguration scheduledConfig) { this.scheduledUpdateIntervalMillis = scheduledConfig.isScheduledUpdateEnabled() ? TimeUnit.SECONDS.toMillis(scheduledConfig.getScheduledUpdateInterval()) : -1L; } - this.requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation(); if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfig) { this.useLatestTs = aggConfig.isUseLatestTs(); } @@ -199,12 +202,39 @@ public class CalculatedFieldCtx implements Closeable { this.tbelInvokeService = systemContext.getTbelInvokeService(); this.relationService = systemContext.getRelationService(); this.alarmService = systemContext.getAlarmService(); + this.cfProcessingService = systemContext.getCalculatedFieldProcessingService(); - this.maxDataPointsPerRollingArg = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); // fixme why tenant profile update is not handled?? this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; } + public boolean isRequiresScheduledReevaluation() { + long now = System.currentTimeMillis(); + long cfCheckIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getCfCheckInterval()); + if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration entityAggregationConfig) { + Watermark watermark = entityAggregationConfig.getWatermark(); + if (watermark != null && watermark.getDuration() > 0) { + return true; + } + long intervalEndTs = entityAggregationConfig.getInterval().getCurrentIntervalEndTs(); + if (now + cfCheckIntervalMillis >= intervalEndTs) { + return true; + } + } + boolean requiresScheduledReevaluation = calculatedField.getConfiguration().requiresScheduledReevaluation(); + if (calculatedField.getConfiguration() instanceof AlarmCalculatedFieldConfiguration) { + long reevaluationIntervalMillis = TimeUnit.SECONDS.toMillis(systemContext.getAlarmRulesReevaluationInterval()); + if (requiresScheduledReevaluation) { + if (now + cfCheckIntervalMillis >= lastReevaluationTs + reevaluationIntervalMillis) { + lastReevaluationTs = now; + return true; + } + return false; + } + } + return requiresScheduledReevaluation; + } + public void init() { switch (cfType) { case SCRIPT -> { @@ -245,9 +275,15 @@ public class CalculatedFieldCtx implements Closeable { }); initialized = true; } + case ENTITY_AGGREGATION -> initialized = true; } } + public void updateTenantProfileProperties() { + this.maxStateSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxStateSizeInKBytes) * 1024; + this.maxSingleValueArgumentSize = systemContext.getApiLimitService().getLimit(tenantId, DefaultTenantProfileConfiguration::getMaxSingleValueArgumentSizeInKBytes) * 1024; + } + public double evaluateSimpleExpression(Expression expression, CalculatedFieldState state) { for (Map.Entry entry : state.getArguments().entrySet()) { try { @@ -606,6 +642,12 @@ public class CalculatedFieldCtx implements Closeable { && (thisConfig.getDeduplicationIntervalInSec() != otherConfig.getDeduplicationIntervalInSec() || !thisConfig.getMetrics().equals(otherConfig.getMetrics()))) { return true; } + if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig + && other.getCalculatedField().getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) { + boolean metricsChanged = thisConfig.getMetrics().equals(otherConfig.getMetrics()); + boolean watermarkChanged = thisConfig.getWatermark().equals(otherConfig.getWatermark()); + return metricsChanged || watermarkChanged; + } return false; } @@ -629,6 +671,9 @@ public class CalculatedFieldCtx implements Closeable { if (hasRelatedEntitiesAggregationConfigurationChanges(other)) { return true; } + if (hasEntityAggregationConfigurationChanges(other)) { + return true; + } return false; } @@ -648,6 +693,14 @@ public class CalculatedFieldCtx implements Closeable { return false; } + private boolean hasEntityAggregationConfigurationChanges(CalculatedFieldCtx other) { + if (calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration thisConfig + && other.calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration otherConfig) { + return !thisConfig.getInterval().equals(otherConfig.getInterval()); + } + return false; + } + private boolean isScheduledUpdateEnabled() { return scheduledUpdateIntervalMillis != -1; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java index c649ab5dbf..e3914cc125 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java @@ -28,6 +28,7 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.service.cf.CalculatedFieldResult; import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; @@ -46,7 +47,8 @@ import static org.thingsboard.server.utils.CalculatedFieldUtils.toSingleValueArg @Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"), @Type(value = AlarmCalculatedFieldState.class, name = "ALARM"), @Type(value = PropagationCalculatedFieldState.class, name = "PROPAGATION"), - @Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION") + @Type(value = RelatedEntitiesAggregationCalculatedFieldState.class, name = "RELATED_ENTITIES_AGGREGATION"), + @Type(value = EntityAggregationCalculatedFieldState.class, name = "ENTITY_AGGREGATION") }) public interface CalculatedFieldState extends Closeable { @@ -61,7 +63,7 @@ public interface CalculatedFieldState extends Closeable { void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx); - void init(); + void init(boolean restored); Map update(Map arguments, CalculatedFieldCtx ctx); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java index 0aaebdfc80..28d7457c1f 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/RelatedEntitiesAggregationCalculatedFieldState.java @@ -74,6 +74,14 @@ public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculat deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec()); } + @Override + public void init(boolean restored) { + super.init(restored); + if (restored) { + scheduleReevaluation(); + } + } + @Override public void close() { super.close(); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java new file mode 100644 index 0000000000..338e667dd2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntry.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state.aggregation.single; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class AggIntervalEntry { + + private Long startTs; + private Long endTs; + + public boolean belongsToInterval(long ts) { + return ts >= startTs && ts < endTs; + } + + public long getIntervalDuration() { + return endTs - startTs; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java new file mode 100644 index 0000000000..fbf344e5d3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/AggIntervalEntryStatus.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state.aggregation.single; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class AggIntervalEntryStatus { + + private long lastArgsRefreshTs = -1; + + private long lastMetricsEvalTs = -1; + + public AggIntervalEntryStatus(long lastArgsRefreshTs) { + this.lastArgsRefreshTs = lastArgsRefreshTs; + } + + public boolean intervalPassed(long checkInterval) { + return lastMetricsEvalTs <= System.currentTimeMillis() - checkInterval; + } + + @JsonIgnore + public boolean argsUpdated() { + return lastArgsRefreshTs > -1; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java new file mode 100644 index 0000000000..7ec5098bc3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationArgumentEntry.java @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state.aggregation.single; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; +import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; + +import java.util.Map; + +@Data +public class EntityAggregationArgumentEntry implements ArgumentEntry { + + private Map aggIntervals; + + private boolean forceResetPrevious; + + public EntityAggregationArgumentEntry(Map aggIntervals) { + this.aggIntervals = aggIntervals; + } + + @Override + public ArgumentEntryType getType() { + return ArgumentEntryType.ENTITY_AGGREGATION; + } + + @Override + public Object getValue() { + return aggIntervals; + } + + @Override + public boolean updateEntry(ArgumentEntry entry) { + boolean updated = false; + if (entry instanceof EntityAggregationArgumentEntry entityAggEntry) { + aggIntervals.putAll(entityAggEntry.getAggIntervals()); + } else if (entry instanceof SingleValueArgumentEntry singleValueArgEntry) { + long entryTs = singleValueArgEntry.getTs(); + long argUpdateTs = System.currentTimeMillis(); + for (Map.Entry aggIntervalEntry : aggIntervals.entrySet()) { + if (singleValueArgEntry.isForceResetPrevious()) { + aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs); + updated = true; + continue; + } + if (aggIntervalEntry.getKey().belongsToInterval(entryTs)) { + aggIntervalEntry.getValue().setLastArgsRefreshTs(argUpdateTs); + return true; + } + } + } + return updated; + } + + @Override + public boolean isEmpty() { + return aggIntervals.isEmpty(); + } + + @Override + public JsonNode jsonValue() { + return JacksonUtil.valueToTree(aggIntervals); + } + + @Override + public TbelCfArg toTbelCfArg() { + return null; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java new file mode 100644 index 0000000000..b07600695c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/aggregation/single/EntityAggregationCalculatedFieldState.java @@ -0,0 +1,271 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf.ctx.state.aggregation.single; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.TbUtils; +import org.thingsboard.server.actors.TbActorRef; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.service.cf.CalculatedFieldProcessingService; +import org.thingsboard.server.service.cf.CalculatedFieldResult; +import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; +import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.BaseCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createDefaultMetricArgumentEntry; + +public class EntityAggregationCalculatedFieldState extends BaseCalculatedFieldState { + + private AggInterval interval; + private long watermarkDuration; + private long checkInterval; + private Map metrics; + + private CalculatedFieldProcessingService cfProcessingService; + + public EntityAggregationCalculatedFieldState(EntityId entityId) { + super(entityId); + } + + @Override + public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { + super.setCtx(ctx, actorCtx); + this.cfProcessingService = ctx.getCfProcessingService(); + var configuration = (EntityAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); + Watermark watermark = configuration.getWatermark(); + watermarkDuration = watermark == null ? 0 : TimeUnit.SECONDS.toMillis(watermark.getDuration()); + checkInterval = TimeUnit.SECONDS.toMillis(ctx.getSystemContext().getCfCheckInterval()); + interval = configuration.getInterval(); + metrics = configuration.getMetrics(); + } + + @Override + public void init(boolean restored) { + super.init(restored); + if (restored) { + fillMissingIntervals(); + } + } + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.ENTITY_AGGREGATION; + } + + @Override + public ListenableFuture performCalculation(Map updatedArgs, CalculatedFieldCtx ctx) throws Exception { + createIntervalIfNotExist(); + long now = System.currentTimeMillis(); + + Map> results = new HashMap<>(); + List expiredIntervals = new ArrayList<>(); + getIntervals().forEach((intervalEntry, argIntervalStatuses) -> { + processInterval(now, intervalEntry, argIntervalStatuses, expiredIntervals, results); + }); + removeExpiredIntervals(expiredIntervals); + + Output output = ctx.getOutput(); + ArrayNode result = toResult(results, output.getDecimalsByDefault()); + if (result.isEmpty()) { + return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY); + } + return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() + .type(output.getType()) + .scope(output.getScope()) + .result(result) + .build()); + } + + private void removeExpiredIntervals(List expiredIntervals) { + expiredIntervals.forEach(expiredInterval -> { + arguments.values().stream() + .map(EntityAggregationArgumentEntry.class::cast) + .forEach(arg -> arg.getAggIntervals().remove(expiredInterval)); + }); + } + + private void createIntervalIfNotExist() { + AggIntervalEntry currentInterval = new AggIntervalEntry(interval.getCurrentIntervalStartTs(), interval.getCurrentIntervalEndTs()); + arguments.forEach((argName, argumentEntry) -> { + var entityAggEntry = (EntityAggregationArgumentEntry) argumentEntry; + entityAggEntry.getAggIntervals().computeIfAbsent(currentInterval, current -> new AggIntervalEntryStatus()); + }); + } + + private void fillMissingIntervals() { + ZoneId zoneId = interval.getZoneId(); + long currentIntervalEndTs = interval.getCurrentIntervalEndTs(); + + Map> intervals = getIntervals(); + AggIntervalEntry lastIntervalEntry = intervals.keySet().stream().max(Comparator.comparing(AggIntervalEntry::getEndTs)).orElse(null); + if (lastIntervalEntry == null) { + return; + } + + ZonedDateTime nextStart = Instant.ofEpochMilli(lastIntervalEntry.getEndTs()).atZone(zoneId); + ZonedDateTime nextEnd = interval.getNextIntervalStart(nextStart); + + while (nextEnd.toInstant().toEpochMilli() <= currentIntervalEndTs) { + long nextStartTs = nextStart.toInstant().toEpochMilli(); + long nextEndTs = nextEnd.toInstant().toEpochMilli(); + AggIntervalEntry missing = new AggIntervalEntry(nextStartTs, nextEndTs); + + arguments.forEach((argName, argumentEntry) -> { + var entityAggEntry = (EntityAggregationArgumentEntry) argumentEntry; + AggIntervalEntryStatus intervalEntryStatus = new AggIntervalEntryStatus(System.currentTimeMillis()); + entityAggEntry.getAggIntervals().computeIfAbsent(missing, missingInterval -> intervalEntryStatus); + }); + + nextStart = nextEnd; + nextEnd = interval.getNextIntervalStart(nextStart); + } + } + + private Map> getIntervals() { + Map> intervals = new HashMap<>(); + arguments.forEach((argName, entry) -> { + var argEntry = (EntityAggregationArgumentEntry) entry; + argEntry.getAggIntervals().forEach((intervalEntry, status) -> + intervals.computeIfAbsent(intervalEntry, i -> new HashMap<>()).put(argName, status) + ); + }); + return intervals; + } + + private void processInterval(long now, + AggIntervalEntry intervalEntry, + Map args, + List expiredIntervals, + Map> results) { + long startTs = intervalEntry.getStartTs(); + long endTs = intervalEntry.getEndTs(); + + if (now - endTs > watermarkDuration) { + handleExpiredInterval(intervalEntry, args, results); + expiredIntervals.add(intervalEntry); + } else if (now - startTs >= intervalEntry.getIntervalDuration()) { + handleActiveInterval(intervalEntry, args, results); + } + } + + private void handleExpiredInterval(AggIntervalEntry intervalEntry, + Map args, + Map> results) { + args.forEach((argName, argEntryIntervalStatus) -> { + if (argEntryIntervalStatus.getLastArgsRefreshTs() > argEntryIntervalStatus.getLastMetricsEvalTs()) { + argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis()); + processMetric(intervalEntry, argName, false, results); + } else if (argEntryIntervalStatus.getLastMetricsEvalTs() == -1) { + argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis()); + processMetric(intervalEntry, argName, true, results); + } + }); + } + + private void handleActiveInterval(AggIntervalEntry intervalEntry, + Map args, + Map> results) { + args.forEach((argName, argEntryIntervalStatus) -> { + if (argEntryIntervalStatus.intervalPassed(checkInterval)) { + if (argEntryIntervalStatus.argsUpdated()) { + argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis()); + argEntryIntervalStatus.setLastArgsRefreshTs(-1); + processMetric(intervalEntry, argName, false, results); + } else if (argEntryIntervalStatus.getLastMetricsEvalTs() == -1) { + argEntryIntervalStatus.setLastMetricsEvalTs(System.currentTimeMillis()); + processMetric(intervalEntry, argName, true, results); + } + } + }); + } + + private void processMetric(AggIntervalEntry intervalEntry, + String argName, + boolean useDefault, + Map> results) { + String metricName = findMetricName(argName); + if (metricName != null) { + AggMetric metric = metrics.get(metricName); + String argKey = ctx.getArguments().get(argName).getRefEntityKey().getKey(); + ArgumentEntry metricEntry = useDefault + ? createDefaultMetricArgumentEntry(argKey, metric) + : cfProcessingService.fetchMetricDuringInterval(ctx.getTenantId(), entityId, argKey, metric, intervalEntry); + if (!metricEntry.isEmpty()) { + results.computeIfAbsent(intervalEntry, i -> new HashMap<>()).put(metricName, metricEntry); + } + } + } + + private String findMetricName(String argName) { + return metrics.entrySet().stream() + .filter(e -> ((AggKeyInput) e.getValue().getInput()).getKey().equals(argName)) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + } + + protected ArrayNode toResult(Map> results, Integer precision) { + ArrayNode result = JacksonUtil.newArrayNode(); + results.forEach((interval, args) -> { + ObjectNode metricsNode = JacksonUtil.newObjectNode(); + for (Map.Entry entry : args.entrySet()) { + String metricName = entry.getKey(); + ArgumentEntry argumentEntry = entry.getValue(); + if (!argumentEntry.isEmpty()) { + Object resultValue = argumentEntry.getValue() instanceof Number number + ? TbUtils.roundResult(number.doubleValue(), precision) + : argumentEntry.getValue(); + metricsNode.put(metricName, JacksonUtil.toString(resultValue)); + } + } + if (!metricsNode.isEmpty()) { + ObjectNode resultNode = JacksonUtil.newObjectNode(); + resultNode.put("ts", interval.getEndTs() - 1); + resultNode.set("values", metricsNode); + result.add(resultNode); + } + }); + return result; + } + + @Override + public boolean isReady() { + return true; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index b96fde9898..342f7534c2 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -122,8 +122,8 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { } @Override - public void init() { - super.init(); + public void init(boolean restored) { + super.init(restored); AtomicBoolean reevalNeeded = new AtomicBoolean(false); Map createRules = configuration.getCreateRules(); for (AlarmSeverity severity : AlarmSeverity.values()) { diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index f48f4621ef..500ad60df0 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -17,7 +17,6 @@ package org.thingsboard.server.service.install; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.thingsboard.server.service.install.update.DefaultDataUpdateService; @@ -25,14 +24,13 @@ import org.thingsboard.server.service.install.update.DefaultDataUpdateService; import java.util.List; @Service -@Profile("install") @Slf4j @RequiredArgsConstructor public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSettingsService { - // This list should include all versions which are compatible for the upgrade. - // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after new release. - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1"); + // This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.). + // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release. + private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0"); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; @@ -80,7 +78,7 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti @Override public String getPackageSchemaVersion() { if (packageSchemaVersion == null) { - packageSchemaVersion = projectInfo.getProjectVersion(); + packageSchemaVersion = normalizeVersion(projectInfo.getProjectVersion()); } return packageSchemaVersion; } @@ -88,17 +86,28 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti @Override public String getDbSchemaVersion() { if (schemaVersionFromDb == null) { - Long version = getSchemaVersionFromDb(); - if (version == null) { + Long dbVersion = getSchemaVersionFromDb(); + if (dbVersion == null) { onSchemaSettingsError("Upgrade failed: the database schema version is missing."); } @SuppressWarnings("DataFlowIssue") - long major = version / 1000000; - long minor = (version % 1000000) / 1000; - long patch = version % 1000; - - schemaVersionFromDb = major + "." + minor + "." + patch; + long version = dbVersion; + + if (version < 1_000_000_000) { + // Old format: MMM mmm ppp (e.g., 4002001 = 4.2.1) + long major = version / 1_000_000; + long minor = (version % 1_000_000) / 1000; + long maintenance = version % 1000; + schemaVersionFromDb = major + "." + minor + "." + maintenance + ".0"; + } else { + // New format: MMM mmm mmm ppp (e.g., 4002001001 = 4.2.1.1) + long major = version / 1_000_000_000; + long minor = (version % 1_000_000_000) / 1_000_000; + long maintenance = (version % 1_000_000) / 1000; + long patch = version % 1000; + schemaVersionFromDb = major + "." + minor + "." + maintenance + "." + patch; + } } return schemaVersionFromDb; } @@ -116,13 +125,26 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti long major = Integer.parseInt(versionParts[0]); long minor = Integer.parseInt(versionParts[1]); - long patch = versionParts.length > 2 ? Integer.parseInt(versionParts[2]) : 0; + long maintenance = Integer.parseInt(versionParts[2]); + long patch = Integer.parseInt(versionParts[3]); - return major * 1000000 + minor * 1000 + patch; + return major * 1_000_000_000L + minor * 1_000_000L + maintenance * 1000L + patch; } private void onSchemaSettingsError(String message) { Runtime.getRuntime().addShutdownHook(new Thread(() -> log.error(message))); throw new RuntimeException(message); } + + private String normalizeVersion(String version) { + String[] parts = version.split("\\."); + + int major = Integer.parseInt(parts[0]); + int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0; + + return major + "." + minor + "." + maintenance + "." + patch; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 4e44517ca0..f9b5c282a8 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -65,9 +65,6 @@ import java.util.stream.Stream; import static org.thingsboard.server.utils.LwM2mObjectModelUtils.toLwm2mResource; -/** - * Created by ashvayka on 18.04.18. - */ @Component @Slf4j public class InstallScripts { @@ -134,6 +131,10 @@ public class InstallScripts { return Paths.get(getDataDir(), JSON_DIR, EDGE_DIR, RULE_CHAINS_DIR); } + public Path getWidgetTypesDir() { + return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); + } + public String getDataDir() { if (!StringUtils.isEmpty(dataDir)) { if (!Paths.get(this.dataDir).toFile().isDirectory()) { @@ -237,7 +238,7 @@ public class InstallScripts { } ); } - Path widgetTypesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); + Path widgetTypesDir = getWidgetTypesDir(); if (Files.exists(widgetTypesDir)) { try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java index 6e162256a4..fa6c280cb7 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -166,6 +166,7 @@ public abstract class AbstractConsumerService 4.2.1.1). + */ +@Slf4j +@Component +@TbCoreComponent +@RequiredArgsConstructor +public class SystemPatchApplier { + + private static final long ADVISORY_LOCK_ID = 7536891047216478431L; + + private final JdbcTemplate jdbcTemplate; + private final InstallScripts installScripts; + private final DatabaseSchemaSettingsService schemaSettingsService; + private final WidgetTypeService widgetTypeService; + + @PostConstruct + private void init() { + ExecutorService executor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("system-patch-applier")); + executor.submit(() -> { + try { + applyPatchIfNeeded(); + } catch (Exception e) { + log.error("Failed to apply system data patch updates", e); + } finally { + executor.shutdown(); + } + }); + } + + private void applyPatchIfNeeded() { + boolean skipVersionCheck = DefaultDataUpdateService.getEnv("SKIP_PATCH_VERSION_CHECK", false); + if (!skipVersionCheck && !isVersionChanged()) { + return; + } + + if (!acquireAdvisoryLock()) { + log.trace("Could not acquire advisory lock. Another node is processing patch updates."); + return; + } + + try { + int updated = updateWidgetTypes(); + log.info("Updated {} widget types", updated); + + schemaSettingsService.updateSchemaVersion(); + log.info("System data patch update completed successfully"); + + } finally { + releaseAdvisoryLock(); + } + } + + private boolean isVersionChanged() { + String packageVersion = schemaSettingsService.getPackageSchemaVersion(); + String dbVersion = schemaSettingsService.getDbSchemaVersion(); + + log.trace("Package version: {}, DB schema version: {}", packageVersion, dbVersion); + + VersionInfo packageVersionInfo = parseVersion(packageVersion); + VersionInfo dbVersionInfo = parseVersion(dbVersion); + + if (packageVersionInfo == null || dbVersionInfo == null) { + log.warn("Unable to parse versions. Package: {}, DB: {}", packageVersion, dbVersion); + return false; + } + + if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { + return false; + } + + log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); + return true; + } + + private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { + return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor + && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; + } + + private int updateWidgetTypes() { + AtomicInteger updated = new AtomicInteger(); + Path widgetTypesDir = installScripts.getWidgetTypesDir(); + + if (!Files.exists(widgetTypesDir)) { + log.trace("Widget types directory does not exist: {}", widgetTypesDir); + return 0; + } + + try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach( + path -> { + try { + if (updateWidgetTypeFromFile(path)) { + updated.incrementAndGet(); + } + } catch (Exception e) { + log.error("Unable to update widget type from json: [{}]", path.toString()); + throw new RuntimeException("Unable to update widget type from json", e); + } + } + ); + } + + return updated.get(); + } + + private boolean updateWidgetTypeFromFile(Path filePath) { + JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); + WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); + String fqn = fileWidgetType.getFqn(); + + WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); + if (existingWidgetType == null) { + // We expect only update here, so it's probably never happening, but for test purpose leave it like this: + throw new RuntimeException("Widget type not found: " + fqn); + } + if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { + existingWidgetType.setDescription(fileWidgetType.getDescription()); + existingWidgetType.setName(fileWidgetType.getName()); + existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); + widgetTypeService.saveWidgetType(existingWidgetType); + log.trace("Updated widget type: {}", fqn); + return true; + } + + log.trace("Widget type unchanged: {}", fqn); + return false; + } + + private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { + if (!isDescriptorEqual(existing.getDescriptor(), file.getDescriptor())) { + return true; + } + + if (!Objects.equals(existing.getName(), file.getName())) { + return true; + } + + return !Objects.equals(existing.getDescription(), file.getDescription()); + } + + private boolean isDescriptorEqual(JsonNode desc1, JsonNode desc2) { + if (desc1 == null && desc2 == null) { + return true; + } + if (desc1 == null || desc2 == null) { + return false; + } + + try { + String hash1 = computeChecksum(desc1); + String hash2 = computeChecksum(desc2); + return Objects.equals(hash1, hash2); + } catch (Exception e) { + log.warn("Failed to compare descriptors using checksum, falling back to equals", e); + return desc1.equals(desc2); + } + } + + private String computeChecksum(JsonNode node) { + String canonicalString = JacksonUtil.toCanonicalString(node); + if (canonicalString == null) { + return null; + } + return Hashing.sha256().hashBytes(canonicalString.getBytes()).toString(); + } + + private boolean acquireAdvisoryLock() { + try { + Boolean acquired = jdbcTemplate.queryForObject( + "SELECT pg_try_advisory_lock(?)", + Boolean.class, + ADVISORY_LOCK_ID + ); + if (Boolean.TRUE.equals(acquired)) { + log.trace("Acquired advisory lock"); + return true; + } + return false; + } catch (Exception e) { + log.error("Failed to acquire advisory lock", e); + return false; + } + } + + private void releaseAdvisoryLock() { + try { + jdbcTemplate.queryForObject( + "SELECT pg_advisory_unlock(?)", + Boolean.class, + ADVISORY_LOCK_ID + ); + log.debug("Released advisory lock"); + } catch (Exception e) { + log.error("Failed to release advisory lock", e); + } + } + + private VersionInfo parseVersion(String version) { + try { + String[] parts = version.split("\\."); + int major = Integer.parseInt(parts[0]); + int minor = parts.length > 1 ? Integer.parseInt(parts[1]) : 0; + int maintenance = parts.length > 2 ? Integer.parseInt(parts[2]) : 0; + int patch = parts.length > 3 ? Integer.parseInt(parts[3]) : 0; + return new VersionInfo(major, minor, maintenance, patch); + } catch (Exception e) { + log.error("Failed to parse version: {}", version, e); + return null; + } + } + + private Stream listDir(Path dir) { + try { + return Files.list(dir); + } catch (NoSuchFileException e) { + return Stream.empty(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + public record VersionInfo(int major, int minor, int maintenance, int patch) {} + +} diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java index 0c0d401688..97d53ac252 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldArgumentUtils.java @@ -21,6 +21,7 @@ import com.google.common.util.concurrent.MoreExecutors; import org.apache.commons.lang3.math.NumberUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; @@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; @@ -35,10 +37,17 @@ import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntryStatus; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; public class CalculatedFieldArgumentUtils { @@ -55,6 +64,36 @@ public class CalculatedFieldArgumentUtils { } } + public static ArgumentEntry transformTsRollingArgument(List tsRolling, int limit, long argTimeWindow) { + return ArgumentEntry.createTsRollingArgument(tsRolling, limit, argTimeWindow); + } + + public static ArgumentEntry transformAggMetricArgument(List timeSeries, String argKey, AggMetric aggMetric) { + if (timeSeries == null || timeSeries.isEmpty()) { + return createDefaultMetricArgumentEntry(argKey, aggMetric); + } + return ArgumentEntry.createSingleValueArgument(timeSeries.get(0)); + } + + public static ArgumentEntry createDefaultMetricArgumentEntry(String argKey, AggMetric metric) { + Long defaultValue = metric.getDefaultValue(); + if (defaultValue != null) { + return ArgumentEntry.createSingleValueArgument(new DoubleDataEntry(argKey, defaultValue.doubleValue())); + } + return new SingleValueArgumentEntry(); + } + + public static ArgumentEntry transformAggregationArgument(List timeSeries, long startIntervalTs, long endIntervalTs) { + Map aggIntervals = new HashMap<>(); + AggIntervalEntry aggIntervalEntry = new AggIntervalEntry(startIntervalTs, endIntervalTs); + if (timeSeries == null || timeSeries.isEmpty()) { + aggIntervals.put(aggIntervalEntry, new AggIntervalEntryStatus()); + } else { + aggIntervals.put(aggIntervalEntry, new AggIntervalEntryStatus(System.currentTimeMillis())); + } + return new EntityAggregationArgumentEntry(aggIntervals); + } + public static KvEntry createDefaultKvEntry(Argument argument) { String key = argument.getRefEntityKey().getKey(); String defaultValue = argument.getDefaultValue(); @@ -83,6 +122,7 @@ public class CalculatedFieldArgumentUtils { case ALARM -> new AlarmCalculatedFieldState(entityId); case PROPAGATION -> new PropagationCalculatedFieldState(entityId); case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(entityId); + case ENTITY_AGGREGATION -> new EntityAggregationCalculatedFieldState(entityId); }; } diff --git a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java index 121febea7d..710ce48ef6 100644 --- a/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java +++ b/application/src/main/java/org/thingsboard/server/utils/CalculatedFieldUtils.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.util.KvProtoUtil; import org.thingsboard.server.common.util.ProtoUtils; import org.thingsboard.server.gen.transport.TransportProtos.AlarmRuleStateProto; import org.thingsboard.server.gen.transport.TransportProtos.AlarmStateProto; +import org.thingsboard.server.gen.transport.TransportProtos.ArgumentIntervalProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldEntityCtxIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldIdProto; import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; @@ -47,6 +48,10 @@ import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.TsRollingArgumentEntry; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesAggregationCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.AggIntervalEntryStatus; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationArgumentEntry; +import org.thingsboard.server.service.cf.ctx.state.aggregation.single.EntityAggregationCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmCalculatedFieldState; import org.thingsboard.server.service.cf.ctx.state.alarm.AlarmRuleState; import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; @@ -105,6 +110,10 @@ public class CalculatedFieldUtils { relatedEntitiesArgumentEntry.getEntityInputs() .forEach((entityId, entry) -> builder.addSingleValueArguments(toSingleValueArgumentProto(argName, (SingleValueArgumentEntry) entry))); } + case ENTITY_AGGREGATION -> { + EntityAggregationArgumentEntry entityAggregationArgumentEntry = (EntityAggregationArgumentEntry) argEntry; + entityAggregationArgumentEntry.getAggIntervals().forEach((interval, argumentStatus) -> builder.addAggregationArguments(toArgumentIntervalProto(argName, interval, argumentStatus))); + } } }); if (state instanceof AlarmCalculatedFieldState alarmState) { @@ -158,6 +167,16 @@ public class CalculatedFieldUtils { return builder.build(); } + public static ArgumentIntervalProto toArgumentIntervalProto(String argName, AggIntervalEntry intervalEntry, AggIntervalEntryStatus argumentStatus) { + return ArgumentIntervalProto.newBuilder() + .setArgName(argName) + .setStartTs(intervalEntry.getStartTs()) + .setEndTs(intervalEntry.getEndTs()) + .setLastArgsRefreshTs(argumentStatus.getLastArgsRefreshTs()) + .setLastMetricsEvalTs(argumentStatus.getLastMetricsEvalTs()) + .build(); + } + public static TsRollingArgumentProto toRollingArgumentProto(String argName, TsRollingArgumentEntry entry) { TsRollingArgumentProto.Builder builder = TsRollingArgumentProto.newBuilder() .setKey(argName) @@ -204,6 +223,7 @@ public class CalculatedFieldUtils { case ALARM -> new AlarmCalculatedFieldState(id.entityId()); case PROPAGATION -> new PropagationCalculatedFieldState(id.entityId()); case RELATED_ENTITIES_AGGREGATION -> new RelatedEntitiesAggregationCalculatedFieldState(id.entityId()); + case ENTITY_AGGREGATION -> new EntityAggregationCalculatedFieldState(id.entityId()); }; if (state instanceof RelatedEntitiesAggregationCalculatedFieldState relatedEntitiesAggState) { @@ -221,6 +241,21 @@ public class CalculatedFieldUtils { return relatedEntitiesAggState; } + if (state instanceof EntityAggregationCalculatedFieldState entityAggregationState) { + Map arguments = new HashMap<>(); + + proto.getAggregationArgumentsList().forEach(argProto -> { + AggIntervalEntry intervalEntry = new AggIntervalEntry(argProto.getStartTs(), argProto.getEndTs()); + AggIntervalEntryStatus intervalStatus = new AggIntervalEntryStatus(argProto.getLastArgsRefreshTs(), argProto.getLastMetricsEvalTs()); + EntityAggregationArgumentEntry argEntry = arguments.computeIfAbsent(argProto.getArgName(), name -> new EntityAggregationArgumentEntry(new HashMap<>())); + argEntry.getAggIntervals().put(intervalEntry, intervalStatus); + }); + + entityAggregationState.getArguments().putAll(arguments); + + return entityAggregationState; + } + proto.getSingleValueArgumentsList().forEach(argProto -> state.getArguments().put(argProto.getArgName(), fromSingleValueArgumentProto(argProto))); diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 889df54848..51b1c0ae26 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -529,6 +529,8 @@ actors: configuration: "${ACTORS_CALCULATED_FIELD_DEBUG_MODE_RATE_LIMITS_PER_TENANT_CONFIGURATION:50000:3600}" # Time in seconds to receive calculation result. calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" + # Interval in seconds to check calculated fields for re-evaluation interval. 1 minute by default. + check_interval: "${ACTORS_CALCULATED_FIELDS_CHECK_INTERVAL_SEC:60}" alarms: # Interval in seconds to re-evaluate Alarm rules that have a time schedule. 2 minutes by default. reevaluation_interval: "${ACTORS_ALARMS_REEVALUATION_INTERVAL_SEC:120}" @@ -1157,7 +1159,7 @@ transport: # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used # - A value that are > 4: MultiNodeConnectionIdGenerator is used - connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:}" + connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}" server: # LwM2M Server ID id: "${LWM2M_SERVER_ID:123}" @@ -1345,7 +1347,7 @@ coap: # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used # - A value that are > 4: MultiNodeConnectionIdGenerator is used - connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:}" + connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}" # Specify the MTU (Maximum Transmission Unit). # Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU. # Default = 1024 diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index b4174e357b..2ac5d59b3a 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -86,6 +86,7 @@ import static org.testcontainers.shaded.org.awaitility.Awaitility.await; @Slf4j @DaoSqlTest @TestPropertySource(properties = { + "actors.calculated_fields.check_interval=1", "actors.alarms.reevaluation_interval=1" }) public class AlarmRulesTest extends AbstractControllerTest { diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java new file mode 100644 index 0000000000..e479c4959e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -0,0 +1,255 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.cf; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.OutputType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.CustomInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL; + +@DaoSqlTest +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@TestPropertySource(properties = { + "actors.calculated_fields.check_interval=1" +}) +public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest { + + private Tenant savedTenant; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + + updateDefaultTenantProfileConfig(tenantProfileConfig -> { + tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); + tenantProfileConfig.setMinAllowedAggregationIntervalInSecForCF(1); + }); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = saveTenant(tenant); + assertThat(savedTenant).isNotNull(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant@thingsboard.org"); + tenantAdmin.setFirstName("John"); + tenantAdmin.setLastName("Doe"); + + createUserAndLogin(tenantAdmin, "testPassword"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + deleteTenant(savedTenant.getId()); + } + + @Test + public void testCreateCfAndNoTelemetryDuringInterval_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + long intervalEndTs = customInterval.getCurrentIntervalEndTs(); + + CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null); + long interval = customInterval.getCurrentIntervalDurationMillis(); + + await().alias("create CF and no telemetry during interval -> save metric with default value") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("9999"); + }); + } + + @Test + public void testCreateCfWithoutWatermark_checkAggregation() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000; + long tsInInterval_1 = currentIntervalStartTs + 1000; + long tsInInterval_2 = currentIntervalStartTs + 500; + long tsInInterval_3 = currentIntervalStartTs + 200; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, null); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":500}}", tsInInterval_1)); + + await().alias("update telemetry that belongs to previous interval -> no aggregation since watermark is not set ") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + }); + } + + @Test + public void testCreateCfWithWatermark_checkAggregationDuringWatermark() throws Exception { + Device device = createDevice("Device", "1234567890111"); + + CustomInterval customInterval = new CustomInterval("Europe/Kyiv", 0L, 5L); + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); + long currentIntervalEndTs = customInterval.getCurrentIntervalEndTs(); + + long tsBeforeInterval = currentIntervalStartTs - 1000L; + long tsInInterval_1 = currentIntervalStartTs + 1000L; + long tsInInterval_2 = currentIntervalStartTs + 500L; + long tsInInterval_3 = currentIntervalStartTs + 200L; + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsBeforeInterval)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":100}}", tsInInterval_1)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); + + long interval = customInterval.getCurrentIntervalDurationMillis(); + Watermark watermark = new Watermark(10); + CalculatedField totalConsumptionCF = createTotalConsumptionCF(device.getId(), customInterval, watermark); + + await().alias("create CF -> perform aggregation after interval end") + .atMost(2 * interval, TimeUnit.MILLISECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("400"); + }); + + postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":300}}", tsInInterval_1)); + + await().alias("update telemetry during watermark -> perform aggregation") + .atMost(2 * 10, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(device.getId(), "consumption"); + assertThat(result).isNotNull(); + assertThat(result.get("consumption").get(0).get("value").asText()).isEqualTo("600"); + }); + } + + private CalculatedField createTotalConsumptionCF(EntityId entityId, AggInterval aggInterval, Watermark watermark) { + Map arguments = new HashMap<>(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + arguments.put("en", argument); + + Map aggMetrics = new HashMap<>(); + + AggMetric consumption = new AggMetric(); + consumption.setFunction(AggFunction.SUM); + consumption.setInput(new AggKeyInput("en")); + consumption.setDefaultValue(9999L); + aggMetrics.put("consumption", consumption); + + Output output = new Output(); + output.setType(OutputType.TIME_SERIES); + output.setDecimalsByDefault(0); + + return createAggCf("Consumption per minute", entityId, + aggInterval, + watermark, + arguments, + aggMetrics, + output); + } + + private CalculatedField createAggCf(String name, + EntityId entityId, + AggInterval aggInterval, + Watermark watermark, + Map inputs, + Map metrics, + Output output) { + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setName(name); + calculatedField.setEntityId(entityId); + calculatedField.setType(CalculatedFieldType.ENTITY_AGGREGATION); + + EntityAggregationCalculatedFieldConfiguration configuration = new EntityAggregationCalculatedFieldConfiguration(); + + configuration.setArguments(inputs); + configuration.setMetrics(metrics); + configuration.setInterval(aggInterval); + if (watermark != null) { + configuration.setWatermark(watermark); + } + configuration.setOutput(output); + + calculatedField.setConfiguration(configuration); + calculatedField.setDebugSettings(DebugSettings.all()); + return saveCalculatedField(calculatedField); + } + + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index b99b416c10..7b22694b87 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -31,6 +31,11 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -44,6 +49,7 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; @@ -165,6 +171,34 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { .andExpect(status().isOk()); } + @Test + public void testSaveEntityAggregationCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getCalculatedField(testDevice.getId(), CalculatedFieldType.ENTITY_AGGREGATION); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getId()).isNotNull(); + assertThat(savedCalculatedField.getCreatedTime()).isGreaterThan(0); + assertThat(savedCalculatedField.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(savedCalculatedField.getEntityId()).isEqualTo(calculatedField.getEntityId()); + assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); + assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); + assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getEntityAggregationCalculatedFieldConfig()); + assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); + + savedCalculatedField.setName("Test CF"); + + CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + @Test public void testSavePropagationCalculatedFieldWithNullArguments() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); @@ -236,6 +270,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { case SIMPLE -> calculatedField.setConfiguration(getSimpleCalculatedFieldConfig()); case GEOFENCING -> calculatedField.setConfiguration(getGeofencingCalculatedFieldConfig()); case PROPAGATION -> calculatedField.setConfiguration(getPropagationCalculatedFieldConfig()); + case ENTITY_AGGREGATION -> calculatedField.setConfiguration(getEntityAggregationCalculatedFieldConfig()); } calculatedField.setVersion(1L); return calculatedField; @@ -280,6 +315,26 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { return config; } + private CalculatedFieldConfiguration getEntityAggregationCalculatedFieldConfig() { + var config = new EntityAggregationCalculatedFieldConfiguration(); + + Argument energyArgument = new Argument(); + energyArgument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("en", energyArgument)); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("en")); + metric.setDefaultValue(9999L); + config.setMetrics(Map.of("consumption", metric)); + + config.setWatermark(new Watermark(TimeUnit.DAYS.toSeconds(1))); + config.setInterval(new HourInterval("Europe/Kiev", TimeUnit.MINUTES.toSeconds(15))); + + config.setOutput(new TimeSeriesOutput()); + + return config; + } + private CalculatedFieldConfiguration getSimpleCalculatedFieldConfig() { SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java index 6b846b3b15..24a0f0294a 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldStateTest.java @@ -105,7 +105,7 @@ public class GeofencingCalculatedFieldStateTest { ctx.init(); state = new GeofencingCalculatedFieldState(ctx.getEntityId()); state.setCtx(ctx, null); - state.init(); + state.init(false); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java index d5546f4fea..6ef945e4c6 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/PropagationCalculatedFieldStateTest.java @@ -105,7 +105,7 @@ public class PropagationCalculatedFieldStateTest { state = new PropagationCalculatedFieldState(ctx.getEntityId()); state.setCtx(ctx, null); - state.init(); + state.init(false); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java index 96ca21c769..97ce49561d 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldStateTest.java @@ -88,7 +88,7 @@ public class ScriptCalculatedFieldStateTest { ctx.init(); state = new ScriptCalculatedFieldState(ctx.getEntityId()); state.setCtx(ctx, null); - state.init(); + state.init(false); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java index 0568f899d7..5634ffeb86 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/SimpleCalculatedFieldStateTest.java @@ -80,7 +80,7 @@ public class SimpleCalculatedFieldStateTest { ctx.init(); state = new SimpleCalculatedFieldState(ctx.getEntityId()); state.setCtx(ctx, null); - state.init(); + state.init(false); } @Test diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index dbfca2e9f1..65633c6bd9 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -37,9 +37,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -/** - * @author Andrew Shvayka - */ @TestPropertySource(properties = { "transport.http.enabled=true", "transport.http.max_payload_size=/api/v1/*/rpc/**=10000;/api/v1/**=20000" diff --git a/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java b/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java index 84a366b139..fea9d5086c 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseRestApiLimitsTest.java @@ -45,10 +45,6 @@ import java.util.concurrent.TimeoutException; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -/** - * @author Illia Barkov - */ - @Slf4j public abstract class BaseRestApiLimitsTest extends AbstractControllerTest { diff --git a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java index 6ff57a7ee0..f976156a86 100644 --- a/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java +++ b/application/src/test/java/org/thingsboard/server/system/RestTemplateConvertersTest.java @@ -29,7 +29,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; - @Slf4j public class RestTemplateConvertersTest { diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java new file mode 100644 index 0000000000..3c65f19d65 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -0,0 +1,410 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.system; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.service.install.InstallScripts; +import org.thingsboard.server.service.system.SystemPatchApplier; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class SystemPatchApplierTest { + + @Mock + private JdbcTemplate jdbcTemplate; + + @Mock + private InstallScripts installScripts; + + @Mock + private WidgetTypeService widgetTypeService; + + @InjectMocks + private SystemPatchApplier reconciler; + + @TempDir + Path tempDir; + + @ParameterizedTest(name = "Parse version {0} should return major={1}, minor={2}, patch={3}") + @CsvSource({ + "4.2.1, 4, 2, 1, 0", + "4.2.0, 4, 2, 0, 0", + "4.2, 4, 2, 0, 0", + "4.0.1.2, 4, 0, 1, 2", + "4, 4, 0, 0, 0", + "1.0.5.7, 1, 0, 5, 7", + "10.20.30.40, 10, 20, 30, 40", + "0.0.1, 0, 0, 1, 0" + }) + void testParseVersion(String versionString, int expectedMajor, int expectedMinor, int expectedMaintenance, int expectedPatch) { + SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", versionString); + + assertNotNull(version, "Version should not be null for: " + versionString); + assertEquals(expectedMajor, version.major(), "Major version mismatch"); + assertEquals(expectedMinor, version.minor(), "Minor version mismatch"); + assertEquals(expectedMaintenance, version.maintenance(), "Maintenance version mismatch"); + assertEquals(expectedPatch, version.patch(), "Patch version mismatch"); + } + + @ParameterizedTest(name = "Parse invalid version: {0}") + @CsvSource({ + "invalid", + "a.b.c", + "1.2.y.x", + "''", + "1.x.3" + }) + void testParseInvalidVersion(String invalidVersion) { + SystemPatchApplier.VersionInfo version = ReflectionTestUtils.invokeMethod(reconciler, "parseVersion", invalidVersion); + assertNull(version, "Version should be null for invalid input: " + invalidVersion); + } + + @Test + void whenLockIsNotAcquired_thenAcquiredIsSuccess() { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(true); + + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + + assertEquals(Boolean.TRUE, acquired); + verify(jdbcTemplate).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + } + + @Test + void whenLockIsAlreadyAcquired_thenAcquiredIsFailed() { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())).thenReturn(false); + + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + + assertNotEquals(Boolean.TRUE, acquired); + } + + @Test + void testReleaseAdvisoryLock() { + when(jdbcTemplate.queryForObject(anyString(), eq(Boolean.class), anyLong())) + .thenReturn(true); + + ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); + + verify(jdbcTemplate).queryForObject( + contains("pg_advisory_unlock"), eq(Boolean.class), anyLong()); + } + + @Test + void whenWidgetNotFound_thenThrowException() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); + String json = JacksonUtil.toString(testWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); + } + + @Test + void whenDescriptorChanged_thenUpdateTheExistingWidget() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); + fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}")); + String json = JacksonUtil.toString(fileWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}")); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) + .thenReturn(existingWidget); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertEquals(1, updated); + verify(widgetTypeService).saveWidgetType(argThat(w -> + w.getDescriptor().get("version").asInt() == 2 + )); + } + + @Test + void whenNameChanged_thenUpdateTheExistingWidget() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "New Name"); + String json = JacksonUtil.toString(fileWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Old Name"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) + .thenReturn(existingWidget); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertEquals(1, updated); + verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); + } + + @Test + void whenNothingChanged_thenSkipTheUpdateOfTheExistingWidget() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); + String json = JacksonUtil.toString(fileWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) + .thenReturn(existingWidget); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertEquals(0, updated); + verify(widgetTypeService, never()).saveWidgetType(any()); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("provideDescriptorComparisonTestCases") + void testIfDescriptorsAreEqual(String testName, JsonNode desc1, JsonNode desc2, boolean expectedEqual) { + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isDescriptorEqual", desc1, desc2); + assertEquals(expectedEqual, result, testName); + } + + @Test + void whenDescriptorChanged_thenReturnWidgetTypeChanged() { + WidgetTypeDetails existing = createTestWidgetType("test", "Test"); + existing.setDescriptor(JacksonUtil.toJsonNode("{\"version\":1}")); + + WidgetTypeDetails file = createTestWidgetType("test", "Test"); + file.setDescriptor(JacksonUtil.toJsonNode("{\"version\":2}")); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertTrue(result); + } + + @Test + void whenNameChanged_thenReturnWidgetTypeChanged() { + WidgetTypeDetails existing = createTestWidgetType("test", "Old Name"); + WidgetTypeDetails file = createTestWidgetType("test", "New Name"); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertTrue(result); + } + + @Test + void whenDescriptionChanged_thenReturnWidgetTypeChanged() { + WidgetTypeDetails existing = createTestWidgetType("test", "Test"); + existing.setDescription("Old description"); + + WidgetTypeDetails file = createTestWidgetType("test", "Test"); + file.setDescription("New description"); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertTrue(result); + } + + @Test + void whenWidgetTypeAreIdentical_thenNoUpdateIsPerformed() { + WidgetTypeDetails existing = createTestWidgetType("test", "Test"); + WidgetTypeDetails file = createTestWidgetType("test", "Test"); + + boolean result = Boolean.TRUE.equals(ReflectionTestUtils.invokeMethod(reconciler, "isWidgetTypeChanged", existing, file)); + assertFalse(result); + } + + @Test + void whenLockIsHeldByOneThread_thenSecondThreadCannotAcquireLock() throws Exception { + CountDownLatch lockAcquired = new CountDownLatch(1); + CountDownLatch startSecondThread = new CountDownLatch(1); + CountDownLatch testComplete = new CountDownLatch(1); + + AtomicBoolean firstThreadAcquiredLock = new AtomicBoolean(false); + AtomicBoolean secondThreadAcquiredLock = new AtomicBoolean(false); + AtomicBoolean firstThreadSavedWidget = new AtomicBoolean(false); + AtomicBoolean secondThreadSavedWidget = new AtomicBoolean(false); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails fileWidget = createTestWidgetType("test_widget", "Test Widget"); + fileWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}")); + String toString = JacksonUtil.toCanonicalString(fileWidget); + assertNotNull(toString); + Files.writeString(widgetTypesDir.resolve("test_widget.json"), toString); + + WidgetTypeDetails existingWidget = createTestWidgetType("test_widget", "Test Widget"); + existingWidget.setId(new WidgetTypeId(UUID.randomUUID())); + existingWidget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}")); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(existingWidget); + + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())) + .thenReturn(true) + .thenReturn(false); + + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())) + .thenReturn(true); + + // The first thread-acquires lock and performs update + Thread firstThread = new Thread(() -> { + try { + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + firstThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); + + if (firstThreadAcquiredLock.get()) { + lockAcquired.countDown(); + startSecondThread.await(5, TimeUnit.SECONDS); + + // Simulate work while holding lock + Thread.sleep(100); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + firstThreadSavedWidget.set(updated != null && updated > 0); + + ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); + } + } catch (Exception ignored) { + } finally { + testComplete.countDown(); + } + }); + + // Second thread - attempts to acquire lock but fails + Thread secondThread = new Thread(() -> { + try { + lockAcquired.await(5, TimeUnit.SECONDS); + startSecondThread.countDown(); + + Boolean acquired = ReflectionTestUtils.invokeMethod(reconciler, "acquireAdvisoryLock"); + secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); + + if (secondThreadAcquiredLock.get()) { + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + secondThreadSavedWidget.set(updated != null && updated > 0); + + ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); + } + } catch (Exception ignored) {} + }); + + firstThread.start(); + secondThread.start(); + + assertTrue(testComplete.await(10, TimeUnit.SECONDS), "Test should complete within timeout"); + firstThread.join(1000); + secondThread.join(1000); + + assertTrue(firstThreadAcquiredLock.get(), "First thread should acquire lock"); + assertFalse(secondThreadAcquiredLock.get(), "Second thread should NOT acquire lock"); + assertTrue(firstThreadSavedWidget.get(), "First thread should save widget"); + assertFalse(secondThreadSavedWidget.get(), "Second thread should NOT save widget"); + + verify(widgetTypeService, times(1)).saveWidgetType(any()); + } + + private static Stream provideDescriptorComparisonTestCases() { + return Stream.of( + Arguments.of("Both null", null, null, true), + Arguments.of("First null", null, JacksonUtil.newObjectNode(), false), + Arguments.of("Second null", JacksonUtil.newObjectNode(), null, false), + Arguments.of("Same content", + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + true), + Arguments.of("Different content", + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":2}"), + false), + Arguments.of("Different key order but same content", + JacksonUtil.toJsonNode("{\"version\":1,\"type\":\"latest\"}"), + JacksonUtil.toJsonNode("{\"type\":\"latest\",\"version\":1}"), + true), + Arguments.of("Empty objects", + JacksonUtil.toJsonNode("{}"), + JacksonUtil.toJsonNode("{}"), + true) + ); + } + + private WidgetTypeDetails createTestWidgetType(String fqn, String name) { + WidgetTypeDetails widget = new WidgetTypeDetails(); + widget.setFqn(fqn); + widget.setName(name); + widget.setDescription("Test description"); + widget.setTenantId(TenantId.SYS_TENANT_ID); + widget.setDescriptor(JacksonUtil.toJsonNode("{\"type\":\"latest\"}")); + return widget; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java b/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java index 74f3cbafd9..27e5237fd9 100644 --- a/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/system/sql/DeviceApiSqlTest.java @@ -18,9 +18,6 @@ package org.thingsboard.server.system.sql; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.system.BaseHttpDeviceApiTest; -/** - * Created by Valerii Sosliuk on 6/27/2017. - */ @DaoSqlTest public class DeviceApiSqlTest extends BaseHttpDeviceApiTest { } diff --git a/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java b/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java index 56c26fb3d3..932fb32328 100644 --- a/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java +++ b/application/src/test/java/org/thingsboard/server/system/sql/RestApiLimitsSqlTest.java @@ -18,7 +18,6 @@ package org.thingsboard.server.system.sql; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.system.BaseRestApiLimitsTest; - @DaoSqlTest public class RestApiLimitsSqlTest extends BaseRestApiLimitsTest { } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java index 7a0b0f8580..a3229a7c19 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength0Test.java @@ -29,10 +29,12 @@ import org.thingsboard.server.dao.service.DaoSqlTest; public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength0Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + private static final Integer serverDtlsCidLength = 0; + protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { - testNoSecDtlsCidLength(dtlsCidLength, 0); + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { - testPskDtlsCidLength(dtlsCidLength, 0); + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java new file mode 100644 index 0000000000..1f5d2d9f19 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength16Test.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + + +@TestPropertySource(properties = { + "transport.lwm2m.dtls.connection_id_length=16" +}) + +@DaoSqlTest +@Slf4j +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength16Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + private static final Integer serverDtlsCidLength = 16; + + protected void testNoSecDtlsCidLength(Integer clientDtlsCidLength) throws Exception { + testNoSecDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); + } + + protected void testPskDtlsCidLength(Integer clientDtlsCidLength) throws Exception { + testPskDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java similarity index 78% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java index 8a65e28975..a36a618bcf 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength3Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength1Test.java @@ -21,17 +21,20 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @TestPropertySource(properties = { - "transport.lwm2m.dtls.connection_id_length=3" + "transport.lwm2m.dtls.connection_id_length=1" }) @DaoSqlTest @Slf4j -public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength3Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength1Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + + private static final Integer serverDtlsCidLength = 1; protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { - testNoSecDtlsCidLength(dtlsCidLength, 3); + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { - testPskDtlsCidLength(dtlsCidLength, 3); + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java new file mode 100644 index 0000000000..1cb657e4a4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength2Test.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + + +@TestPropertySource(properties = { + "transport.lwm2m.dtls.connection_id_length=2" +}) + +@DaoSqlTest +@Slf4j +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength2Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + private static final Integer serverDtlsCidLength = 2; + + protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } + protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java new file mode 100644 index 0000000000..56e544243f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLength4Test.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.dao.service.DaoSqlTest; + + +@TestPropertySource(properties = { + "transport.lwm2m.dtls.connection_id_length=4" +}) + +@DaoSqlTest +@Slf4j +public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLength4Test extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + + private static final Integer serverDtlsCidLength = 4; + + protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } + protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + } +} diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java index 9d52920072..6dea2f54d0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest.java @@ -28,11 +28,12 @@ import org.thingsboard.server.dao.service.DaoSqlTest; @Slf4j public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthNullTest extends AbstractSecurityLwM2MIntegrationDtlsCidLengthTest { + private static final Integer serverDtlsCidLength = null; protected void testNoSecDtlsCidLength(Integer dtlsCidLength) throws Exception { - testNoSecDtlsCidLength(dtlsCidLength, null); + testNoSecDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } protected void testPskDtlsCidLength(Integer dtlsCidLength) throws Exception { - testPskDtlsCidLength(dtlsCidLength, null); + testPskDtlsCidLength(dtlsCidLength, serverDtlsCidLength); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java index eb17f2cf7e..04afef0c61 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/AbstractSecurityLwM2MIntegrationDtlsCidLengthTest.java @@ -17,14 +17,23 @@ package org.thingsboard.server.transport.lwm2m.security.cid; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.dtls.Connection; +import org.eclipse.californium.scandium.dtls.ConnectionId; +import org.eclipse.californium.scandium.dtls.InMemoryReadWriteLockConnectionStore; +import org.eclipse.californium.scandium.dtls.ResumptionSupportingConnectionStore; import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpoint; import org.eclipse.leshan.client.californium.endpoint.CaliforniumClientEndpointsProvider; +import org.eclipse.leshan.client.servers.LwM2mServer; +import org.eclipse.leshan.core.peer.IpPeer; import org.junit.Assert; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.transport.lwm2m.security.AbstractSecurityLwM2MIntegrationTest; +import java.lang.reflect.Field; +import java.net.InetSocketAddress; import java.util.concurrent.TimeUnit; import static org.awaitility.Awaitility.await; @@ -39,13 +48,13 @@ public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthTest extends protected String awaitAlias; - protected void testNoSecDtlsCidLength(Integer dtlsCidLength, Integer serverDtlsCidLength) throws Exception { + protected void testNoSecDtlsCidLength(Integer clientDtlsCidLength, Integer serverDtlsCidLength) throws Exception { initDeviceCredentialsNoSek(); - basicTestConnectionDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + basicTestConnectionDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); } - protected void testPskDtlsCidLength(Integer dtlsCidLength, Integer serverDtlsCidLength) throws Exception { + protected void testPskDtlsCidLength(Integer clientDtlsCidLength, Integer serverDtlsCidLength) throws Exception { initDeviceCredentialsPsk(); - basicTestConnectionDtlsCidLength(dtlsCidLength, serverDtlsCidLength); + basicTestConnectionDtlsCidLength(clientDtlsCidLength, serverDtlsCidLength); } protected void basicTestConnectionDtlsCidLength(Integer clientDtlsCidLength, @@ -69,19 +78,55 @@ public abstract class AbstractSecurityLwM2MIntegrationDtlsCidLengthTest extends Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().isEmpty()); } else { Assert.assertEquals(2L, lwM2MTestClient.getClientDtlsCid().size()); - Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().keySet().contains(ON_READ_CONNECTION_ID)); - Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().keySet().contains(ON_WRITE_CONNECTION_ID)); - if (serverDtlsCidLength == null) { + Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().containsKey(ON_READ_CONNECTION_ID)); + Assert.assertTrue(lwM2MTestClient.getClientDtlsCid().containsKey(ON_WRITE_CONNECTION_ID)); + + LwM2mServer lwM2mServer = lwM2MTestClient.getLeshanClient().getRegisteredServers().entrySet().stream().findFirst().get().getValue(); + CaliforniumClientEndpoint lwM2mClientEndpoint = (CaliforniumClientEndpoint) lwM2MTestClient.getLeshanClient().getEndpoint(lwM2mServer); + Connection connection = getConnection(lwM2mClientEndpoint, lwM2mServer); + ConnectionId clientCid = connection.getConnectionId(); + ConnectionId readCid = connection.getEstablishedDtlsContext().getReadConnectionId(); + ConnectionId serverCid = connection.getEstablishedDtlsContext().getWriteConnectionId(); + if (serverDtlsCidLength == null || clientDtlsCidLength == null) { + // cid is not used Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID)); Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID)); + Assert.assertNull(readCid); + Assert.assertNull(serverCid); } else { + Assert.assertEquals(serverDtlsCidLength, lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID)); Assert.assertEquals(clientDtlsCidLength, lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID)); - if (clientDtlsCidLength == null) { - Assert.assertNull(lwM2MTestClient.getClientDtlsCid().get(ON_READ_CONNECTION_ID)); + // cid used + Assert.assertNotNull(clientCid); + Assert.assertNotNull(readCid); + if (clientDtlsCidLength > 0) { + Assert.assertEquals(clientCid, readCid); + } + Assert.assertNotNull(serverCid); + int actualServerCidLength = serverCid.getBytes().length; + int expectedServerCidLength = serverDtlsCidLength; + Assert.assertEquals(expectedServerCidLength, actualServerCidLength); + } + + if (clientCid != null) { + int actualClientCidLength = clientCid.getBytes().length; + int expectedClientCidLength; + if (clientDtlsCidLength == null || clientDtlsCidLength == 0) { + expectedClientCidLength = 3; } else { - Assert.assertEquals(Integer.valueOf(serverDtlsCidLength), lwM2MTestClient.getClientDtlsCid().get(ON_WRITE_CONNECTION_ID)); + expectedClientCidLength = clientDtlsCidLength; } + Assert.assertEquals(expectedClientCidLength, actualClientCidLength); } } } + + private static Connection getConnection(CaliforniumClientEndpoint lwM2mClientEndpoint, LwM2mServer lwM2mServer) throws NoSuchFieldException, IllegalAccessException { + DTLSConnector connector = (DTLSConnector) lwM2mClientEndpoint.getCoapEndpoint().getConnector(); + Field field = DTLSConnector.class.getDeclaredField("connectionStore"); + field.setAccessible(true); + ResumptionSupportingConnectionStore connectionStore = (InMemoryReadWriteLockConnectionStore) field.get(connector); + InetSocketAddress serverAddr = ((IpPeer) lwM2mServer.getTransportData()).getSocketAddress(); + return connectionStore.get(serverAddr); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java index 6d529b3c08..2b5cccd7be 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/NoSecLwM2MIntegrationDtlsCidLengthTest.java @@ -27,7 +27,7 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 @Before public void setUpNoSecDtlsCidLength() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE)); - awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = 0"; + awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = 0"; } @Test @@ -40,8 +40,23 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 testNoSecDtlsCidLength(0); } + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testNoSecDtlsCidLength(1); + } + @Test public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { - testNoSecDtlsCidLength(2); + testNoSecDtlsCidLength(1); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testNoSecDtlsCidLength(4); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testNoSecDtlsCidLength(16); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java index f478a18777..c08eeeaa79 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_0/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -27,7 +27,7 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI @Before public void createProfileRpc() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); - awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = 0"; + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 0"; } @Test @@ -39,10 +39,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { testPskDtlsCidLength(0); } + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } @Test public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testPskDtlsCidLength(2); } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java new file mode 100644 index 0000000000..b2c06495fc --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_1/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_1; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength0Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength1Test; + +import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; + +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength1Test { + + @Before + public void createProfileRpc() { + transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 1"; + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception { + testPskDtlsCidLength(null); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { + testPskDtlsCidLength(0); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { + testPskDtlsCidLength(2); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java similarity index 69% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java index a395f2e7e3..872608145c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/NoSecLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/NoSecLwM2MIntegrationDtlsCidLengthTest.java @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_3; +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_16; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength3Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength16Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test; import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.NO_SEC; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; -public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength3Test { +public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength16Test { @Before public void setUpNoSecDtlsCidLength() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE)); - awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = 3"; + awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = 16"; } @Test @@ -40,8 +41,23 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 testNoSecDtlsCidLength(0); } + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testNoSecDtlsCidLength(1); + } + @Test public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testNoSecDtlsCidLength(2); } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testNoSecDtlsCidLength(4); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testNoSecDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java new file mode 100644 index 0000000000..579614d98e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_16/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_16; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength16Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test; + +import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; + +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength16Test { + + @Before + public void createProfileRpc() { + transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 16"; + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception { + testPskDtlsCidLength(null); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { + testPskDtlsCidLength(0); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { + testPskDtlsCidLength(2); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java similarity index 73% rename from application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java rename to application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java index 868a146ed7..2d68d057d8 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_3/PskLwm2mIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_2/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -13,21 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_3; +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_2; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength3Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength2Test; import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; -public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength3Test { +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength2Test { @Before public void createProfileRpc() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); - awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = 3"; + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 2"; } @Test @@ -40,9 +40,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI testPskDtlsCidLength(0); } + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + @Test public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testPskDtlsCidLength(2); } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java new file mode 100644 index 0000000000..6994e19fbf --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_4/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.security.cid.serverDtlsCidLength_4; + +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.security.cid.AbstractSecurityLwM2MIntegrationDtlsCidLength4Test; + +import static org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode.PSK; +import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MProfileBootstrapConfigType.NONE; + +public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MIntegrationDtlsCidLength4Test { + + @Before + public void createProfileRpc() { + transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = 4"; + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_Null() throws Exception { + testPskDtlsCidLength(null); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_0() throws Exception { + testPskDtlsCidLength(0); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { + testPskDtlsCidLength(2); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } +} + diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java index 9e7424743a..e85e03dfed 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/NoSecLwM2MIntegrationDtlsCidLengthTest.java @@ -27,7 +27,7 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 @Before public void setUpNoSecDtlsCidLength() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(NO_SEC, NONE)); - awaitAlias = "await on client state (NoSec_Lwm2m) DtlsCidLength = Null"; + awaitAlias = "await on client state (NoSec_Lwm2m) serverDtlsCidLength = Null"; } @Test @@ -41,7 +41,22 @@ public class NoSecLwM2MIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2 } @Test - public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { - testNoSecDtlsCidLength(2); + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testNoSecDtlsCidLength(1); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testNoSecDtlsCidLength(4); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_8() throws Exception { + testNoSecDtlsCidLength(8); + } + + @Test + public void testWithNoSecConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testNoSecDtlsCidLength(16); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java index 8a8f01b3ab..e482e1b106 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/cid/serverDtlsCidLength_null/PskLwm2mIntegrationDtlsCidLengthTest.java @@ -27,7 +27,7 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI @Before public void createProfileRpc() { transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); - awaitAlias = "await on client state (Psk_Lwm2m) DtlsCidLength = Null"; + awaitAlias = "await on client state (Psk_Lwm2m) serverDtlsCidLength = Null"; } @Test @@ -40,9 +40,24 @@ public class PskLwm2mIntegrationDtlsCidLengthTest extends AbstractSecurityLwM2MI testPskDtlsCidLength(0); } + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_1() throws Exception { + testPskDtlsCidLength(1); + } + @Test public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_2() throws Exception { testPskDtlsCidLength(2); } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_4() throws Exception { + testPskDtlsCidLength(4); + } + + @Test + public void testWithPskConnectLwm2mSuccessClientDtlsCidLength_16() throws Exception { + testPskDtlsCidLength(16); + } } diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java index 56705b5608..f98dc7d3c6 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java @@ -66,7 +66,7 @@ public class TbCoapDtlsSettings { @Value("${coap.dtls.retransmission_timeout:9000}") private int dtlsRetransmissionTimeout; - @Value("${coap.dtls.connection_id_length:}") + @Value("${coap.dtls.connection_id_length:8}") private Integer cIdLength; @Value("${coap.dtls.max_transmission_unit:1024}") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 6a475daae3..0fa9b2dd78 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -41,5 +41,6 @@ public class SystemParams { int minAllowedScheduledUpdateIntervalInSecForCF; int maxRelationLevelPerCfArgument; long minAllowedDeduplicationIntervalInSecForCF; + long minAllowedAggregationIntervalInSecForCF; TrendzSettings trendzSettings; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java index 4463c835db..de36b43a40 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedFieldType.java @@ -26,7 +26,8 @@ public enum CalculatedFieldType { GEOFENCING, ALARM, PROPAGATION, - RELATED_ENTITIES_AGGREGATION; + RELATED_ENTITIES_AGGREGATION, + ENTITY_AGGREGATION; public static final Set all = Collections.unmodifiableSet(EnumSet.allOf(CalculatedFieldType.class)); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index 5313dea0bf..886b304aed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -44,7 +45,8 @@ import java.util.stream.Collectors; @Type(value = GeofencingCalculatedFieldConfiguration.class, name = "GEOFENCING"), @Type(value = AlarmCalculatedFieldConfiguration.class, name = "ALARM"), @Type(value = PropagationCalculatedFieldConfiguration.class, name = "PROPAGATION"), - @Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION") + @Type(value = RelatedEntitiesAggregationCalculatedFieldConfiguration.class, name = "RELATED_ENTITIES_AGGREGATION"), + @Type(value = EntityAggregationCalculatedFieldConfiguration.class, name = "ENTITY_AGGREGATION") }) @JsonIgnoreProperties(ignoreUnknown = true) public interface CalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java index ebd612b1e0..355ca2c72d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggMetric.java @@ -27,5 +27,6 @@ public class AggMetric { private AggFunction function; private String filter; private AggInput input; + private Long defaultValue; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java new file mode 100644 index 0000000000..f6095d41a7 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java @@ -0,0 +1,96 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.AggInterval; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.Watermark; + +import java.util.Map; + +@Data +public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { + + private Map arguments; + @Valid + @NotEmpty + private Map metrics; + @Valid + @NotNull + private AggInterval interval; + @Valid + private Watermark watermark; + @Valid + @NotNull + private Output output; + + @Override + public CalculatedFieldType getType() { + return CalculatedFieldType.ENTITY_AGGREGATION; + } + + @Override + public void validate() { + validateArguments(); + validateMetrics(); + validateInterval(); + } + + private void validateArguments() { + if (arguments.containsKey("ctx")) { + throw new IllegalArgumentException("Argument name 'ctx' is reserved and cannot be used."); + } + if (arguments.values().stream().anyMatch(argument -> !ArgumentType.TS_LATEST.equals(argument.getRefEntityKey().getType()))) { + throw new IllegalArgumentException("Calculated field with type: '" + getType() + "' support only TS_LATEST arguments."); + } + } + + private void validateMetrics() { + if (metrics == null || metrics.isEmpty()) { + throw new IllegalArgumentException("Metrics map cannot be empty."); + } + + for (AggMetric metric : metrics.values()) { + if (metric.getInput() instanceof AggKeyInput aggKeyInput) { + if (!arguments.containsKey(aggKeyInput.getKey())) { + throw new IllegalArgumentException( + "Metric references unknown argument: '" + aggKeyInput.getKey() + "'." + ); + } + } else { + throw new IllegalArgumentException("Metric key can only refer to argument."); + } + } + } + + private void validateInterval() { + if (interval == null) { + throw new IllegalArgumentException("Interval must be defined."); + } + interval.validate(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java new file mode 100644 index 0000000000..3d38ebc1f6 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +import java.time.ZoneId; +import java.time.ZonedDateTime; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "type" +) +@JsonSubTypes({ + @JsonSubTypes.Type(value = HourInterval.class, name = "HOUR"), + @JsonSubTypes.Type(value = DayInterval.class, name = "DAY"), + @JsonSubTypes.Type(value = WeekInterval.class, name = "WEEK"), + @JsonSubTypes.Type(value = WeekSunSatInterval.class, name = "WEEK_SUN_SAT"), + @JsonSubTypes.Type(value = MonthInterval.class, name = "MONTH"), + @JsonSubTypes.Type(value = QuarterInterval.class, name = "QUARTER"), + @JsonSubTypes.Type(value = YearInterval.class, name = "YEAR"), + @JsonSubTypes.Type(value = CustomInterval.class, name = "CUSTOM") +}) +@JsonIgnoreProperties(ignoreUnknown = true) +public interface AggInterval { + + @JsonIgnore + AggIntervalType getType(); + + @JsonIgnore + ZoneId getZoneId(); + + @JsonIgnore + long getCurrentIntervalDurationMillis(); + + @JsonIgnore + long getCurrentIntervalStartTs(); + + long getDateTimeIntervalStartTs(ZonedDateTime dateTime); + + @JsonIgnore + long getCurrentIntervalEndTs(); + + long getDateTimeIntervalEndTs(ZonedDateTime dateTime); + + ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart); + + void validate(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java new file mode 100644 index 0000000000..1d39f14a03 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalType.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +public enum AggIntervalType { + + HOUR, + DAY, + WEEK, + WEEK_SUN_SAT, + MONTH, + QUARTER, + YEAR, + CUSTOM + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java new file mode 100644 index 0000000000..0c0801dea9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/BaseAggInterval.java @@ -0,0 +1,108 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import com.fasterxml.jackson.annotation.JsonInclude; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.TimeUnit; + +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +@AllArgsConstructor +@NoArgsConstructor +public abstract class BaseAggInterval implements AggInterval { + + @NotBlank + protected String tz; + protected Long offsetSec; // delay seconds since start of interval + + @Override + public ZoneId getZoneId() { + return ZoneId.of(tz); + } + + protected long getOffsetSafe() { + return offsetSec != null ? offsetSec : 0L; + } + + @Override + public long getCurrentIntervalDurationMillis() { + return getCurrentIntervalEndTs() - getCurrentIntervalStartTs(); + } + + @Override + public long getCurrentIntervalStartTs() { + ZoneId zoneId = getZoneId(); + ZonedDateTime now = ZonedDateTime.now(zoneId); + return getDateTimeIntervalStartTs(now); + } + + @Override + public long getDateTimeIntervalStartTs(ZonedDateTime dateTime) { + long offset = getOffsetSafe(); + ZonedDateTime shiftedNow = dateTime.minusSeconds(offset); + ZonedDateTime alignedStart = getAlignedBoundary(shiftedNow, false); + ZonedDateTime actualStart = alignedStart.plusSeconds(offset); + return actualStart.toInstant().toEpochMilli(); + } + + @Override + public long getCurrentIntervalEndTs() { + ZoneId zoneId = getZoneId(); + ZonedDateTime now = ZonedDateTime.now(zoneId); + return getDateTimeIntervalEndTs(now); + } + + @Override + public long getDateTimeIntervalEndTs(ZonedDateTime dateTime) { + long offset = getOffsetSafe(); + ZonedDateTime shiftedNow = dateTime.minusSeconds(offset); + ZonedDateTime alignedEnd = getAlignedBoundary(shiftedNow, true); + ZonedDateTime actualEnd = alignedEnd.plusSeconds(offset); + return actualEnd.toInstant().toEpochMilli(); + } + + protected abstract ZonedDateTime alignToIntervalStart(ZonedDateTime reference); + + protected ZonedDateTime getAlignedBoundary(ZonedDateTime reference, boolean next) { + ZonedDateTime base = alignToIntervalStart(reference); + return next ? getNextIntervalStart(base) : base; + } + + @Override + public void validate() { + try { + getZoneId(); + } catch (Exception ex) { + throw new IllegalArgumentException("Invalid timezone in interval: " + ex.getMessage()); + } + if (offsetSec != null) { + if (offsetSec < 0) { + throw new IllegalArgumentException("Offset cannot be negative."); + } + if (TimeUnit.SECONDS.toMillis(offsetSec) >= getCurrentIntervalDurationMillis()) { + throw new IllegalArgumentException("Offset must be greater than interval duration."); + } + } + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java new file mode 100644 index 0000000000..24bfa26d8d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.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.server.common.data.cf.configuration.aggregation.single.interval; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.time.ZonedDateTime; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +public class CustomInterval extends BaseAggInterval { + + @NotNull + @Min(1) + private Long durationSec; + + public CustomInterval(String tz, Long offsetSec, Long durationSec) { + super(tz, offsetSec); + this.durationSec = durationSec; + } + + @Override + public AggIntervalType getType() { + return AggIntervalType.CUSTOM; + } + + @Override + public long getCurrentIntervalDurationMillis() { + return getDurationMillis(); + } + + private long getDurationMillis() { + return Duration.ofSeconds(durationSec).toMillis(); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + ZonedDateTime localMidnight = reference.toLocalDate().atStartOfDay(reference.getZone()); + long secondsFromMidnight = Duration.between(localMidnight, reference).getSeconds(); + long alignedSecondsFromMidnight = (secondsFromMidnight / durationSec) * durationSec; + return localMidnight.plusSeconds(alignedSecondsFromMidnight); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusSeconds(durationSec); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java new file mode 100644 index 0000000000..2ad0ce24d3 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +@Data +@NoArgsConstructor +public class DayInterval extends BaseAggInterval { + + @Override + public AggIntervalType getType() { + return AggIntervalType.DAY; + } + + public DayInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + return reference.truncatedTo(ChronoUnit.DAYS); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusDays(1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java new file mode 100644 index 0000000000..3ce366bc75 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +public class HourInterval extends BaseAggInterval { + + public HourInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + public AggIntervalType getType() { + return AggIntervalType.HOUR; + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + return reference.truncatedTo(ChronoUnit.HOURS); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusHours(1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java new file mode 100644 index 0000000000..3ce121459b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; + +@Data +@NoArgsConstructor +public class MonthInterval extends BaseAggInterval { + + @Override + public AggIntervalType getType() { + return AggIntervalType.MONTH; + } + + public MonthInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + return reference.withDayOfMonth(1).truncatedTo(ChronoUnit.DAYS); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusMonths(1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java new file mode 100644 index 0000000000..96fcfc0dc8 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.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.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZonedDateTime; + +@Data +@NoArgsConstructor +public class QuarterInterval extends BaseAggInterval { + + @Override + public AggIntervalType getType() { + return AggIntervalType.QUARTER; + } + + public QuarterInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + int month = reference.getMonthValue(); + int quarterStartMonth = ((month - 1) / 3) * 3 + 1; // 1, 4, 7, 10 + return ZonedDateTime.of( + LocalDate.of(reference.getYear(), quarterStartMonth, 1), + LocalTime.MIDNIGHT, + reference.getZone()); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusMonths(3); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java new file mode 100644 index 0000000000..b07e6a3012 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/Watermark.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Watermark { + + @Min(0) + private long duration; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java new file mode 100644 index 0000000000..0f70adc5ad --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.DayOfWeek; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; + +@Data +@NoArgsConstructor +public class WeekInterval extends BaseAggInterval { + + @Override + public AggIntervalType getType() { + return AggIntervalType.WEEK; + } + + public WeekInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + return reference.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).truncatedTo(ChronoUnit.DAYS); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusWeeks(1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java new file mode 100644 index 0000000000..2c4482f0b4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.DayOfWeek; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; + +@Data +@NoArgsConstructor +public class WeekSunSatInterval extends BaseAggInterval { + + @Override + public AggIntervalType getType() { + return AggIntervalType.WEEK_SUN_SAT; + } + + public WeekSunSatInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + return reference.with(TemporalAdjusters.previousOrSame(DayOfWeek.SUNDAY)).truncatedTo(ChronoUnit.DAYS); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusWeeks(1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java new file mode 100644 index 0000000000..441f3913a9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZonedDateTime; + +@Data +@NoArgsConstructor +public class YearInterval extends BaseAggInterval { + + @Override + public AggIntervalType getType() { + return AggIntervalType.YEAR; + } + + public YearInterval(String tz, Long offsetSec) { + super(tz, offsetSec); + } + + @Override + protected ZonedDateTime alignToIntervalStart(ZonedDateTime reference) { + return ZonedDateTime.of( + LocalDate.of(reference.getYear(), 1, 1), + LocalTime.MIDNIGHT, + reference.getZone()); + } + + @Override + public ZonedDateTime getNextIntervalStart(ZonedDateTime currentStart) { + return currentStart.plusYears(1); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index 0e246f9268..7587f563ab 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -188,6 +188,8 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private long maxSingleValueArgumentSizeInKBytes = 2; @Schema(example = "60") private long minAllowedDeduplicationIntervalInSecForCF = 60; + @Schema(example = "60") + private long minAllowedAggregationIntervalInSecForCF = 60; @Override public long getProfileThreshold(ApiUsageRecordKey key) { diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java new file mode 100644 index 0000000000..3884b5a214 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfigurationTest.java @@ -0,0 +1,128 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.Output; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunctionInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval.HourInterval; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class EntityAggregationCalculatedFieldConfigurationTest { + + @Test + void typeShouldBeEntityAggregation() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.ENTITY_AGGREGATION); + } + + @ParameterizedTest + @ValueSource(strings = {"ATTRIBUTE", "TS_ROLLING"}) + void validateShouldThrowWhenNotTsLatestArgumentUsed(String argumentType) { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + cfg.setArguments(Map.of("k", validArgument(ArgumentType.valueOf(argumentType)))); + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Calculated field with type: '" + cfg.getType() + "' support only TS_LATEST arguments."); + } + + @Test + void validateShouldThrowWhenMetricMapIsEmpty() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + cfg.setMetrics(Map.of()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Metrics map cannot be empty."); + } + + @Test + void validateShouldThrowWhenMetricInputIsNotAggKeyInput() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggFunctionInput()); // cannot be function + cfg.setMetrics(Map.of("m", metric)); + + cfg.setInterval(new HourInterval("Europe/Kiev", null)); + cfg.setOutput(new Output()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Metric key can only refer to argument."); + } + + @Test + void validateShouldThrowWhenMetricReferencesUnknownArgument() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("unknown")); + cfg.setMetrics(Map.of("m", metric)); + + cfg.setInterval(new HourInterval("Europe/Kiev", null)); + cfg.setOutput(new Output()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Metric references unknown argument: 'unknown'."); + } + + @Test + void validateShouldThrowWhenIntervalIsNull() { + var cfg = new EntityAggregationCalculatedFieldConfiguration(); + + cfg.setArguments(Map.of("k", validArgument(ArgumentType.TS_LATEST))); + cfg.setMetrics(Map.of("m", validMetric())); + cfg.setInterval(null); + cfg.setOutput(new Output()); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Interval must be defined."); + } + + private Argument validArgument(ArgumentType type) { + Argument a = new Argument(); + a.setRefEntityKey(new ReferencedEntityKey("key", type, null)); + return a; + } + + private AggMetric validMetric() { + AggMetric metric = new AggMetric(); + metric.setInput(new AggKeyInput("k")); + return metric; + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java new file mode 100644 index 0000000000..b439c44fef --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggIntervalTest.java @@ -0,0 +1,168 @@ +/** + * Copyright © 2016-2025 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.function.LongFunction; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class AggIntervalTest { + + private static final String TZ = "Europe/Kiev"; + + @Test + void validateShouldThrowWhenInvalidTimZone() { + AggInterval interval = new HourInterval("TimeZone", null); + + assertThatThrownBy(interval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid timezone in interval: "); + } + + @Test + void validateShouldThrowWhenOffsetIsNegative() { + AggInterval interval = new CustomInterval(TZ, -100L, TimeUnit.HOURS.toSeconds(2)); + + assertThatThrownBy(interval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Offset cannot be negative."); + } + + @Test + void validateShouldThrowWhenOffsetGreaterThanIntervalDuration() { + AggInterval interval = new CustomInterval(TZ, TimeUnit.HOURS.toSeconds(2), TimeUnit.HOURS.toSeconds(2)); + + assertThatThrownBy(interval::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Offset must be greater than interval duration."); + } + + @ParameterizedTest + @MethodSource("intervals") + void testGetStartAndEndWithoutOffset(LongFunction intervalCreator) { + AggInterval interval = intervalCreator.apply(0L); + + ZonedDateTime dateTime = ZonedDateTime.of( + // 2025.11.11 00:00:00 + 2025, 11, 11, 0, 0, 0, 0, ZoneId.of(TZ) + ); + long startTs = interval.getDateTimeIntervalStartTs(dateTime); + long endTs = interval.getDateTimeIntervalEndTs(dateTime); + + assertThat(endTs).isGreaterThan(startTs); + assertThat(endTs - startTs).isEqualTo(interval.getCurrentIntervalDurationMillis()); + } + + @ParameterizedTest + @MethodSource("intervals") + void testApplyOffset(LongFunction intervalCreator) { + long offsetSec = TimeUnit.MINUTES.toSeconds(15); + AggInterval intervalWithOffset = intervalCreator.apply(offsetSec); + AggInterval intervalNoOffset = intervalCreator.apply(0L); + + ZonedDateTime dateTime = ZonedDateTime.of( + // 2025.11.11 11:20:00 - chosen so 15m offset shifts into a new interval + 2025, 11, 11, 11, 20, 0, 0, ZoneId.of(TZ) + ); + + long startWithOffsetTs = intervalWithOffset.getDateTimeIntervalStartTs(dateTime); + long startNoOffsetTs = intervalNoOffset.getDateTimeIntervalStartTs(dateTime); + + ZonedDateTime startWithOffset = Instant.ofEpochMilli(startWithOffsetTs).atZone(intervalWithOffset.getZoneId()); + ZonedDateTime startNoOffset = Instant.ofEpochMilli(startNoOffsetTs).atZone(intervalNoOffset.getZoneId()); + + long actualOffset = Duration.between(startNoOffset, startWithOffset).toSeconds(); + assertThat(actualOffset).isEqualTo(offsetSec); + } + + private static Stream intervals() { + return Stream.of( + Arguments.of((LongFunction) offset -> new HourInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new DayInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new WeekInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new WeekSunSatInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new MonthInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new QuarterInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new YearInterval(TZ, offset)), + Arguments.of((LongFunction) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4))) + ); + } + + @ParameterizedTest + @MethodSource("nextIntervalFromExactDate") + void testNextIntervalFromExactDate(LongFunction intervalCreator, Function expectedDateTimeFunction) { + AggInterval interval = intervalCreator.apply(0L); + + ZonedDateTime currentStart = ZonedDateTime.of( + 2025, 11, 11, 0, 0, 0, 0, ZoneId.of(TZ) + ); + + ZonedDateTime nextStart = interval.getNextIntervalStart(currentStart); + + assertThat(nextStart).isEqualTo(expectedDateTimeFunction.apply(currentStart)); + } + + private static Stream nextIntervalFromExactDate() { + return Stream.of( + Arguments.of( + (LongFunction) offset -> new HourInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusHours(1) + ), + Arguments.of( + (LongFunction) offset -> new DayInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusDays(1) + ), + Arguments.of( + (LongFunction) offset -> new WeekInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusWeeks(1) + ), + Arguments.of( + (LongFunction) offset -> new WeekSunSatInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusWeeks(1) + ), + Arguments.of( + (LongFunction) offset -> new MonthInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusMonths(1) + ), + Arguments.of( + (LongFunction) offset -> new QuarterInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusMonths(3) + ), + Arguments.of( + (LongFunction) offset -> new YearInterval(TZ, offset), + (Function) currentInterval -> currentInterval.plusYears(1) + ), + Arguments.of( + (LongFunction) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)), + (Function) currentInterval -> currentInterval.plusHours(4) + ) + ); + } + +} diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 5f33524464..557eda324f 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -916,6 +916,14 @@ message GeofencingArgumentProto { repeated GeofencingZoneProto zones = 2; } +message ArgumentIntervalProto { + string argName = 1; + int64 startTs = 2; + int64 endTs = 3; + int64 lastArgsRefreshTs = 4; + int64 lastMetricsEvalTs = 5; +} + message CalculatedFieldStateProto { CalculatedFieldEntityCtxIdProto id = 1; string type = 2; @@ -925,6 +933,7 @@ message CalculatedFieldStateProto { AlarmStateProto alarmState = 6; int64 lastArgsUpdateTs = 7; int64 lastMetricsEvalTs = 8; + repeated ArgumentIntervalProto aggregationArguments = 9; } //Used to report session state to tb-Service and persist this state in the cache on the tb-Service level. diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java index c1af8f553a..bf65b95dd2 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java @@ -43,7 +43,7 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { private int dtlsRetransmissionTimeout; @Getter - @Value("${transport.lwm2m.dtls.connection_id_length:}") + @Value("${transport.lwm2m.dtls.connection_id_length:8}") private Integer dtlsCidLength; @Getter diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index cd61c7ac20..e3ae70c9a5 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -83,6 +83,12 @@ public class JacksonUtil { .addModule(new Jdk8Module()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build(); + public static final ObjectMapper CANONICAL_JSON_MAPPER = JsonMapper.builder() + .addModule(new Jdk8Module()) + .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true) + .configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true) + .serializationInclusion(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + .build(); public static ObjectMapper getObjectMapperWithJavaTimeModule() { return JsonMapper.builder() @@ -207,6 +213,23 @@ public class JacksonUtil { return data; } + public static String toCanonicalString(Object value) { + try { + if (value == null) { + return null; + } + + if (value instanceof JsonNode) { + Object pojo = CANONICAL_JSON_MAPPER.convertValue(value, Object.class); + return CANONICAL_JSON_MAPPER.writeValueAsString(pojo); + } + + return CANONICAL_JSON_MAPPER.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalArgumentException("The given Json object value cannot be transformed to a canonical String: " + value, e); + } + } + public static T treeToValue(JsonNode node, Class clazz) { try { return OBJECT_MAPPER.treeToValue(node, clazz); diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java index 39faf1bc30..c10da4e6c6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/CalculatedFieldDataValidator.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalcula import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.cf.CalculatedFieldDao; @@ -31,6 +32,7 @@ import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.Map; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Component @@ -49,6 +51,7 @@ public class CalculatedFieldDataValidator extends DataValidator validateSchedulingConfiguration(tenantId, calculatedField); validateRelationQuerySourceArguments(tenantId, calculatedField); validateAggregationConfiguration(tenantId, calculatedField); + validateEntityAggregationConfiguration(tenantId, calculatedField); } @Override @@ -120,10 +123,24 @@ public class CalculatedFieldDataValidator extends DataValidator if (!(calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration aggConfiguration)) { return; } - long minAllowedDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF); - if (aggConfiguration.getDeduplicationIntervalInSec() < minAllowedDeduplicationInterval) { + long minDeduplicationInterval = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedDeduplicationIntervalInSecForCF); + if (aggConfiguration.getDeduplicationIntervalInSec() < minDeduplicationInterval) { throw new IllegalArgumentException("Deduplication interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedDeduplicationInterval); + "minimum allowed interval in tenant profile: " + minDeduplicationInterval); + } + } + + private void validateEntityAggregationConfiguration(TenantId tenantId, CalculatedField calculatedField) { + if (!(calculatedField.getConfiguration() instanceof EntityAggregationCalculatedFieldConfiguration aggConfiguration)) { + return; + } + long minAggregationIntervalInSec = apiLimitService.getLimit(tenantId, DefaultTenantProfileConfiguration::getMinAllowedAggregationIntervalInSecForCF); + if (minAggregationIntervalInSec <= 0) { + return; + } + if (aggConfiguration.getInterval().getCurrentIntervalDurationMillis() < TimeUnit.SECONDS.toMillis(minAggregationIntervalInSec)) { + throw new IllegalArgumentException("Aggregation interval duration is less than configured " + + "minimum allowed aggregation interval in tenant profile: " + minAggregationIntervalInSec + " sec."); } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java index f78cd7c088..51654c4c31 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/DeviceState.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.profile; import com.google.gson.JsonElement; import com.google.gson.JsonParser; +import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbContext; @@ -29,9 +30,14 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.alarm.Alarm; +import org.thingsboard.server.common.data.device.profile.AlarmCondition; import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; +import org.thingsboard.server.common.data.device.profile.AlarmConditionSpec; +import org.thingsboard.server.common.data.device.profile.AlarmConditionSpecType; +import org.thingsboard.server.common.data.device.profile.AlarmRule; import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; +import org.thingsboard.server.common.data.device.profile.DurationAlarmConditionSpec; import org.thingsboard.server.common.data.exception.ApiUsageLimitsExceededException; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -39,6 +45,8 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.query.DynamicValue; +import org.thingsboard.server.common.data.query.DynamicValueSourceType; import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.rule.RuleNodeState; @@ -56,6 +64,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.thingsboard.server.common.data.msg.TbMsgType.ACTIVITY_EVENT; import static org.thingsboard.server.common.data.msg.TbMsgType.ALARM_ACK; @@ -88,6 +97,10 @@ class DeviceState { this.deviceId = deviceId; this.deviceProfile = deviceProfile; + if (hasDurationRulesWithDynamicValueFromCurrentDevice(deviceProfile)) { + latestValues = fetchLatestValues(ctx, deviceId); + } + this.dynamicPredicateValueCtx = new DynamicPredicateValueCtxImpl(ctx.getTenantId(), deviceId, ctx); if (config.isPersistAlarmRulesState()) { @@ -117,7 +130,10 @@ class DeviceState { public void updateProfile(TbContext ctx, DeviceProfile deviceProfile) throws ExecutionException, InterruptedException { Set oldKeys = Set.copyOf(this.deviceProfile.getEntityKeys()); this.deviceProfile.updateDeviceProfile(deviceProfile); - if (latestValues != null) { + + if (latestValues == null && hasDurationRulesWithDynamicValueFromCurrentDevice(this.deviceProfile)) { + latestValues = fetchLatestValues(ctx, deviceId); + } else if (latestValues != null) { Set keysToFetch = new HashSet<>(this.deviceProfile.getEntityKeys()); keysToFetch.removeAll(oldKeys); if (!keysToFetch.isEmpty()) { @@ -135,10 +151,31 @@ class DeviceState { } } + private static boolean hasDurationRulesWithDynamicValueFromCurrentDevice(ProfileState deviceProfile) { + return deviceProfile.getAlarmSettings().stream().anyMatch(DeviceState::isDurationRuleWithDynamicValueFromCurrentDevice); + } + + private static boolean isDurationRuleWithDynamicValueFromCurrentDevice(DeviceProfileAlarm alarm) { + return Stream.concat(alarm.getCreateRules().values().stream(), Stream.ofNullable(alarm.getClearRule())) + .map(AlarmRule::getCondition) + .map(AlarmCondition::getSpec) + .anyMatch(spec -> isDurationRule(spec) && hasDynamicDurationValueFromCurrentDevice((DurationAlarmConditionSpec) spec)); + } + + private static boolean isDurationRule(AlarmConditionSpec spec) { + return spec instanceof DurationAlarmConditionSpec durationSpec && durationSpec.getType() == AlarmConditionSpecType.DURATION; + } + + private static boolean hasDynamicDurationValueFromCurrentDevice(DurationAlarmConditionSpec spec) { + DynamicValue dynamicValue = spec.getPredicate().getDynamicValue(); + return dynamicValue != null && dynamicValue.getSourceType() == DynamicValueSourceType.CURRENT_DEVICE; + } + public void harvestAlarms(TbContext ctx, long ts) throws ExecutionException, InterruptedException { log.debug("[{}] Going to harvest alarms: {}", ctx.getSelfId(), ts); boolean stateChanged = false; for (AlarmState state : alarmStates.values()) { + state.setDataSnapshot(latestValues); stateChanged |= state.process(ctx, ts); } if (persistState && stateChanged) { @@ -348,7 +385,8 @@ class DeviceState { return EntityKeyType.ATTRIBUTE; } - private DataSnapshot fetchLatestValues(TbContext ctx, EntityId originator) throws ExecutionException, InterruptedException { + @SneakyThrows + private DataSnapshot fetchLatestValues(TbContext ctx, EntityId originator) { Set entityKeysToFetch = deviceProfile.getEntityKeys(); DataSnapshot result = new DataSnapshot(entityKeysToFetch); addEntityKeysToSnapshot(ctx, originator, entityKeysToFetch, result); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java index c16d09969b..04e5372bf7 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/profile/TbDeviceProfileNodeTest.java @@ -16,8 +16,8 @@ package org.thingsboard.rule.engine.profile; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.provider.Arguments; @@ -58,6 +58,10 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.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.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; @@ -81,7 +85,6 @@ import java.math.BigDecimal; import java.math.RoundingMode; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.TreeMap; @@ -89,9 +92,17 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +import static com.google.common.util.concurrent.Futures.immediateFuture; +import static java.util.Collections.emptyList; +import static java.util.Collections.singleton; +import static java.util.Collections.singletonList; +import static java.util.Collections.singletonMap; +import static org.assertj.core.api.Assertions.assertThatNoException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType.ATTRIBUTE; @@ -123,16 +134,26 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { private final CustomerId customerId = new CustomerId(UUID.randomUUID()); private final DeviceProfileId deviceProfileId = new DeviceProfileId(UUID.randomUUID()); + @BeforeEach + public void setup() { + lenient().when(ctx.getTenantId()).thenReturn(tenantId); + lenient().when(ctx.getDeviceProfileCache()).thenReturn(cache); + lenient().when(ctx.getTimeseriesService()).thenReturn(timeseriesService); + lenient().when(ctx.getAlarmService()).thenReturn(alarmService); + lenient().when(ctx.getDeviceService()).thenReturn(deviceService); + lenient().when(ctx.getAttributesService()).thenReturn(attributesService); + } + @Test public void testRandomMessageType() throws Exception { init(); DeviceProfile deviceProfile = new DeviceProfile(); DeviceProfileData deviceProfileData = new DeviceProfileData(); - deviceProfileData.setAlarms(Collections.emptyList()); + deviceProfileData.setAlarms(emptyList()); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); ObjectNode data = JacksonUtil.newObjectNode(); data.put("temperature", 42); TbMsg msg = TbMsg.newMsg() @@ -153,10 +174,10 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfile deviceProfile = new DeviceProfile(); DeviceProfileData deviceProfileData = new DeviceProfileData(); - deviceProfileData.setAlarms(Collections.emptyList()); + deviceProfileData.setAlarms(emptyList()); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); ObjectNode data = JacksonUtil.newObjectNode(); data.put("temperature", 42); TbMsg msg = TbMsg.newMsg() @@ -184,20 +205,20 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); AlarmRule clearRule = new AlarmRule(); AlarmCondition clearCondition = getNumericAlarmCondition(TIME_SERIES, "temperature", LESS, 10.0); clearRule.setCondition(clearCondition); dpa.setClearRule(clearRule); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() @@ -258,7 +279,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AlarmConditionFilter highTempFilter = getAlarmConditionFilter(TIME_SERIES, "temperature", GREATER, 50.0); AlarmCondition alarmHighTempCondition = new AlarmCondition(); - alarmHighTempCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmHighTempCondition.setCondition(singletonList(highTempFilter)); AlarmRule alarmHighTempRule = new AlarmRule(); alarmHighTempRule.setCondition(alarmHighTempCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); @@ -272,13 +293,13 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { dpa.setCreateRules(createRules); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm1")).thenReturn(null); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm1")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() @@ -362,7 +383,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { attributeKvEntity.setLastUpdateTs(System.currentTimeMillis()); AttributeKvEntry entry = attributeKvEntity.toData(); - ListenableFuture> attrListListenableFuture = Futures.immediateFuture(Collections.singletonList(entry)); + ListenableFuture> attrListListenableFuture = immediateFuture(singletonList(entry)); AlarmConditionFilter alarmEnabledFilter = new AlarmConditionFilter(); alarmEnabledFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.CONSTANT, "alarmEnabled")); @@ -392,19 +413,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("alarmEnabledAlarmID"); dpa.setAlarmType("alarmEnabledAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(attrListListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -413,7 +434,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { .copyMetaData(TbMsgMetaData.EMPTY) .data(TbMsg.EMPTY_STRING) .build(); - Mockito.when(ctx.newMsg(Mockito.any(), Mockito.any(TbMsgType.class), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyString())) + when(ctx.newMsg(Mockito.any(), Mockito.any(TbMsgType.class), Mockito.any(), Mockito.any(), Mockito.any(), Mockito.anyString())) .thenReturn(theMsg); ObjectNode data = JacksonUtil.newObjectNode(); @@ -455,7 +476,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { attributeKvEntity.setLastUpdateTs(System.currentTimeMillis()); AttributeKvEntry entry = attributeKvEntity.toData(); - ListenableFuture> attrListListenableFuture = Futures.immediateFuture(Optional.of(entry)); + ListenableFuture> attrListListenableFuture = immediateFuture(Optional.of(entry)); AlarmConditionFilter alarmEnabledFilter = new AlarmConditionFilter(); alarmEnabledFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.CONSTANT, "alarmEnabled")); @@ -485,24 +506,24 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("alarmEnabledAlarmID"); dpa.setAlarmType("alarmEnabledAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "alarmEnabledAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) - .thenReturn(Futures.immediateFuture(Optional.empty())); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + .thenReturn(immediateFuture(emptyList())); + when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) + .thenReturn(immediateFuture(Optional.empty())); + when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(attrListListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -550,7 +571,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -564,25 +585,25 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -644,7 +665,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); + immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -658,7 +679,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10L, @@ -676,19 +697,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -736,6 +757,105 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { verify(ctx, Mockito.never()).tellFailure(Mockito.any(), Mockito.any()); } + @Test + public void testCurrentDeviceAttributeForDynamicDurationValue_noMessagesReceivedFromDeviceBeforeAlarmHarvesting() throws Exception { + init(); + + // 1. Setup device profile that has no alarm rules + var deviceProfileData = new DeviceProfileData(); + deviceProfileData.setAlarms(emptyList()); + + var deviceProfile = new DeviceProfile(deviceProfileId); + deviceProfile.setTenantId(tenantId); + deviceProfile.setName("default"); + deviceProfile.setProfileData(deviceProfileData); + + given(cache.get(tenantId, deviceId)).willReturn(deviceProfile); + + // 2. Initialize device state by sending ENTITY_CREATED event + var device = new Device(deviceId); + device.setTenantId(tenantId); + device.setName("device"); + device.setDeviceProfileId(deviceProfileId); + device.setType("default"); + + var entityCreatedEvent = TbMsg.newMsg() + .type(TbMsgType.ENTITY_CREATED) + .originator(deviceId) + .data(JacksonUtil.toString(device)) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + node.onMsg(ctx, entityCreatedEvent); + + // 3. Update device profile so it now has dynamic duration rule with value taken from current device + var predicate = new NumericFilterPredicate(); + predicate.setOperation(NumericOperation.GREATER_OR_EQUAL); + predicate.setValue(new FilterPredicateValue<>(100.0)); + + var filter = new AlarmConditionFilter(); + filter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); + filter.setValueType(EntityKeyValueType.NUMERIC); + filter.setPredicate(predicate); + + var durationSpec = new DurationAlarmConditionSpec(); + durationSpec.setUnit(TimeUnit.SECONDS); + durationSpec.setPredicate( + new FilterPredicateValue<>(10L, null, new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "duration", false)) + ); + + var alarmCondition = new AlarmCondition(); + alarmCondition.setCondition(singletonList(filter)); + alarmCondition.setSpec(durationSpec); + + var alarmRule = new AlarmRule(); + alarmRule.setCondition(alarmCondition); + + var dpa = new DeviceProfileAlarm(); + dpa.setId("c4486528-84f2-bd72-589e-2f9a60f89c17"); + dpa.setAlarmType("Test alarm"); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + + deviceProfileData.setAlarms(singletonList(dpa)); + + // 4. Mock DB calls for keys used in alarm rule + given(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))).willReturn(immediateFuture( + List.of(new BasicTsKvEntry(123L, new DoubleDataEntry("temperature", 55.6))) + )); + + given(attributesService.find(tenantId, deviceId, AttributeScope.CLIENT_SCOPE, singleton("duration"))).willReturn(immediateFuture(emptyList())); + given(attributesService.find(tenantId, deviceId, AttributeScope.SHARED_SCOPE, singleton("duration"))).willReturn(immediateFuture(emptyList())); + given(attributesService.find(tenantId, deviceId, AttributeScope.SERVER_SCOPE, singleton("duration"))).willReturn(immediateFuture( + List.of(new BaseAttributeKvEntry(123L, new LongDataEntry("duration", 20L))) + )); + + // 5. Send DEVICE_PROFILE_UPDATE_SELF_MSG so alarm state (inside device state) gets initialized + given(cache.get(tenantId, deviceProfileId)).willReturn(deviceProfile); + + var deviceProfileUpdateMsg = TbMsg.newMsg() + .originator(tenantId) + .type(TbMsgType.DEVICE_PROFILE_UPDATE_SELF_MSG) + .data(deviceProfileId.toString()) + .metaData(TbMsgMetaData.EMPTY) + .build(); + + node.onMsg(ctx, deviceProfileUpdateMsg); + + // 6. Not sending anything else to simulate no activity + + // 7. Simulate periodic alarm harvesting by manually sending DEVICE_PROFILE_PERIODIC_SELF_MSG message + var periodicCheck = TbMsg.newMsg() + .type(TbMsgType.DEVICE_PROFILE_PERIODIC_SELF_MSG) + .originator(tenantId) + .customerId(customerId) + .metaData(TbMsgMetaData.EMPTY) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + + // NPE should NOT happen here: dynamic value of duration condition should be correctly resolved + assertThatNoException().isThrownBy(() -> node.onMsg(ctx, periodicCheck)); + } + @Test public void testInheritTenantAttributeForDuration() throws Exception { init(); @@ -775,11 +895,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> optionalDurationAttribute = - Futures.immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); + immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); ListenableFuture> listNoDurationAttribute = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); ListenableFuture> emptyOptional = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -793,7 +913,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10L, @@ -811,25 +931,25 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(optionalDurationAttribute); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(emptyOptional); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listNoDurationAttribute); TbMsg theMsg = TbMsg.newMsg() @@ -911,7 +1031,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); + immediateFuture(Arrays.asList(entry, alarmDelayAttributeKvEntry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -925,7 +1045,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10, @@ -943,19 +1063,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -1035,11 +1155,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry alarmDelayAttributeKvEntry = alarmDelayAttributeKvEntity.toData(); ListenableFuture> optionalDurationAttribute = - Futures.immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); + immediateFuture(Optional.of(alarmDelayAttributeKvEntry)); ListenableFuture> listNoDurationAttribute = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); ListenableFuture> emptyOptional = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1053,7 +1173,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( 10, @@ -1070,25 +1190,25 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(tenantId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(optionalDurationAttribute); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(emptyOptional); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listNoDurationAttribute); TbMsg theMsg = TbMsg.newMsg() @@ -1156,7 +1276,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1170,7 +1290,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); FilterPredicateValue filterPredicateValue = new FilterPredicateValue<>( alarmDelayInSeconds, @@ -1188,19 +1308,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -1273,7 +1393,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFuture = - Futures.immediateFuture(Collections.singletonList(entry)); + immediateFuture(singletonList(entry)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1287,7 +1407,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); RepeatingAlarmConditionSpec repeating = new RepeatingAlarmConditionSpec(); repeating.setPredicate(new FilterPredicateValue<>( @@ -1302,19 +1422,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFuture); TbMsg theMsg = TbMsg.newMsg() @@ -1369,7 +1489,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entryActiveSchedule = attributeKvEntityActiveSchedule.toData(); ListenableFuture> listListenableFutureActiveSchedule = - Futures.immediateFuture(Collections.singletonList(entryActiveSchedule)); + immediateFuture(singletonList(entryActiveSchedule)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1383,10 +1503,10 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { )); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); CustomTimeSchedule schedule = new CustomTimeSchedule(); - schedule.setItems(Collections.emptyList()); + schedule.setItems(emptyList()); schedule.setDynamicValue(new DynamicValue<>(DynamicValueSourceType.CURRENT_DEVICE, "dynamicValueActiveSchedule", false)); AlarmRule alarmRule = new AlarmRule(); @@ -1395,19 +1515,19 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm deviceProfileAlarmActiveSchedule = new DeviceProfileAlarm(); deviceProfileAlarmActiveSchedule.setId("highTemperatureAlarmID"); deviceProfileAlarmActiveSchedule.setAlarmType("highTemperatureAlarm"); - deviceProfileAlarmActiveSchedule.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + deviceProfileAlarmActiveSchedule.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(deviceProfileAlarmActiveSchedule)); + deviceProfileData.setAlarms(singletonList(deviceProfileAlarmActiveSchedule)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureActiveSchedule); TbMsg theMsg = TbMsg.newMsg() @@ -1466,7 +1586,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entryInactiveSchedule = attributeKvEntityInactiveSchedule.toData(); ListenableFuture> listListenableFutureInactiveSchedule = - Futures.immediateFuture(Collections.singletonList(entryInactiveSchedule)); + immediateFuture(singletonList(entryInactiveSchedule)); AlarmConditionFilter highTempFilter = new AlarmConditionFilter(); highTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1481,7 +1601,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); CustomTimeSchedule schedule = new CustomTimeSchedule(); @@ -1504,18 +1624,18 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm deviceProfileAlarmNonactiveSchedule = new DeviceProfileAlarm(); deviceProfileAlarmNonactiveSchedule.setId("highTemperatureAlarmID"); deviceProfileAlarmNonactiveSchedule.setAlarmType("highTemperatureAlarm"); - deviceProfileAlarmNonactiveSchedule.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + deviceProfileAlarmNonactiveSchedule.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(deviceProfileAlarmNonactiveSchedule)); + deviceProfileData.setAlarms(singletonList(deviceProfileAlarmNonactiveSchedule)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")) .thenReturn(null); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureInactiveSchedule); TbMsg theMsg = TbMsg.newMsg() @@ -1565,9 +1685,9 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1582,29 +1702,29 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("lessTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1651,9 +1771,9 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1668,27 +1788,27 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("lessTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1739,11 +1859,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> emptyOptionalFuture = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1758,31 +1878,31 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("lessTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "lessTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(emptyOptionalFuture); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1835,11 +1955,11 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { AttributeKvEntry entry = attributeKvEntity.toData(); ListenableFuture> listListenableFutureWithLess = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); ListenableFuture> emptyOptionalFuture = - Futures.immediateFuture(Optional.empty()); + immediateFuture(Optional.empty()); ListenableFuture> optionalListenableFutureWithLess = - Futures.immediateFuture(Optional.of(entry)); + immediateFuture(Optional.of(entry)); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(TIME_SERIES, "temperature")); @@ -1854,31 +1974,31 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { ); lowTempFilter.setPredicate(lowTempPredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(lowTempFilter)); + alarmCondition.setCondition(singletonList(lowTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("lesstempID"); dpa.setAlarmType("greaterTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "greaterTemperatureAlarm")) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "greaterTemperatureAlarm")) .thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); - Mockito.when(ctx.getAttributesService()).thenReturn(attributesService); - Mockito.when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) + when(ctx.getAttributesService()).thenReturn(attributesService); + when(ctx.getDeviceService().findDeviceById(tenantId, deviceId)) .thenReturn(device); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), Mockito.any(AttributeScope.class), Mockito.anySet())) .thenReturn(listListenableFutureWithLess); - Mockito.when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(customerId), Mockito.any(AttributeScope.class), Mockito.anyString())) .thenReturn(emptyOptionalFuture); - Mockito.when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) + when(attributesService.find(eq(tenantId), eq(tenantId), eq(AttributeScope.SERVER_SCOPE), Mockito.anyString())) .thenReturn(optionalListenableFutureWithLess); TbMsg theMsg = TbMsg.newMsg() @@ -1907,15 +2027,12 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { } private void init() throws TbNodeException { - Mockito.when(ctx.getTenantId()).thenReturn(tenantId); - Mockito.when(ctx.getDeviceProfileCache()).thenReturn(cache); - Mockito.lenient().when(ctx.getTimeseriesService()).thenReturn(timeseriesService); - Mockito.lenient().when(ctx.getAlarmService()).thenReturn(alarmService); - Mockito.when(ctx.getDeviceService()).thenReturn(deviceService); - Mockito.lenient().when(ctx.getAttributesService()).thenReturn(attributesService); - TbNodeConfiguration nodeConfiguration = new TbNodeConfiguration(JacksonUtil.newObjectNode()); + var config = new TbDeviceProfileNodeConfiguration(); + config.setFetchAlarmRulesStateOnStart(false); + config.setPersistAlarmRulesState(false); + node = new TbDeviceProfileNode(); - node.init(ctx, nodeConfiguration); + node.init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); } private void registerCreateAlarmMock(AlarmApiCallResult a, boolean created) { @@ -1987,23 +2104,23 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, createRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, createRule))); dpa.setClearRule(clearRule); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); ListenableFuture> tsKvList = - Futures.immediateFuture(Collections.singletonList(getTsKvEntry("temperature", 35L))); + immediateFuture(singletonList(getTsKvEntry("temperature", 35L))); ListenableFuture> attrList = - Futures.immediateFuture(Collections.emptyList()); + immediateFuture(emptyList()); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) .thenReturn(tsKvList); - Mockito.when(attributesService.find(eq(tenantId), eq(deviceId), any(), anySet())) + when(attributesService.find(eq(tenantId), eq(deviceId), any(), anySet())) .thenReturn(attrList); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() @@ -2042,7 +2159,7 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { private AlarmCondition getNumericAlarmCondition(AlarmConditionKeyType alarmConditionKeyType, String key, NumericOperation operation, Double value) { AlarmConditionFilter filter = getAlarmConditionFilter(alarmConditionKeyType, key, operation, value); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(filter)); + alarmCondition.setCondition(singletonList(filter)); return alarmCondition; } @@ -2072,13 +2189,13 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { highTemperaturePredicate.setValue(new FilterPredicateValue<>(30.0)); highTempFilter.setPredicate(highTemperaturePredicate); AlarmCondition alarmCondition = new AlarmCondition(); - alarmCondition.setCondition(Collections.singletonList(highTempFilter)); + alarmCondition.setCondition(singletonList(highTempFilter)); AlarmRule alarmRule = new AlarmRule(); alarmRule.setCondition(alarmCondition); DeviceProfileAlarm dpa = new DeviceProfileAlarm(); dpa.setId("highTemperatureAlarmID"); dpa.setAlarmType("highTemperatureAlarm"); - dpa.setCreateRules(new TreeMap<>(Collections.singletonMap(AlarmSeverity.CRITICAL, alarmRule))); + dpa.setCreateRules(new TreeMap<>(singletonMap(AlarmSeverity.CRITICAL, alarmRule))); AlarmConditionFilter lowTempFilter = new AlarmConditionFilter(); lowTempFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.TIME_SERIES, "temperature")); @@ -2089,17 +2206,17 @@ public class TbDeviceProfileNodeTest extends AbstractRuleNodeUpgradeTest { lowTempFilter.setPredicate(lowTemperaturePredicate); AlarmRule clearRule = new AlarmRule(); AlarmCondition clearCondition = new AlarmCondition(); - clearCondition.setCondition(Collections.singletonList(lowTempFilter)); + clearCondition.setCondition(singletonList(lowTempFilter)); clearRule.setCondition(clearCondition); dpa.setClearRule(clearRule); - deviceProfileData.setAlarms(Collections.singletonList(dpa)); + deviceProfileData.setAlarms(singletonList(dpa)); deviceProfile.setProfileData(deviceProfileData); - Mockito.when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); - Mockito.when(timeseriesService.findLatest(tenantId, deviceId, Collections.singleton("temperature"))) - .thenReturn(Futures.immediateFuture(Collections.emptyList())); - Mockito.when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); + when(cache.get(tenantId, deviceId)).thenReturn(deviceProfile); + when(timeseriesService.findLatest(tenantId, deviceId, singleton("temperature"))) + .thenReturn(immediateFuture(emptyList())); + when(alarmService.findLatestActiveByOriginatorAndType(tenantId, deviceId, "highTemperatureAlarm")).thenReturn(null); registerCreateAlarmMock(alarmService.createAlarm(any()), true); TbMsg theMsg = TbMsg.newMsg() diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index f40a09c753..2f3942f847 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -193,7 +193,7 @@ coap: # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used # - A value that are > 4: MultiNodeConnectionIdGenerator is used - connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:}" + connection_id_length: "${COAP_DTLS_CONNECTION_ID_LENGTH:8}" # Specify the MTU (Maximum Transmission Unit). # Should be used if LAN MTU is not used, e.g. if IP tunnels are used or if the client uses a smaller value than the LAN MTU. # Default = 1024 diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 0895bfa676..323f80b999 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -173,7 +173,7 @@ transport: # - A value of 0 means we accept using CID but will not generate one for foreign peer (enables support but not for incoming traffic). # - A value between 0 and <= 4: SingleNodeConnectionIdGenerator is used # - A value that are > 4: MultiNodeConnectionIdGenerator is used - connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:}" + connection_id_length: "${LWM2M_DTLS_CONNECTION_ID_LENGTH:8}" server: # LwM2M Server ID id: "${LWM2M_SERVER_ID:123}" diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 6e4d324b5b..21759fbca0 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -32,6 +32,7 @@ export interface SysParamsState { maxDataPointsPerRollingArg: number; maxArgumentsPerCF: number; minAllowedDeduplicationIntervalInSecForCF: number; + minAllowedAggregationIntervalInSecForCF: number; minAllowedScheduledUpdateIntervalInSecForCF: number; maxRelationLevelPerCfArgument: number; ruleChainDebugPerTenantLimitsConfiguration?: string; diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 777cf5308e..af040a6d53 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -34,6 +34,7 @@ const emptyUserAuthState: AuthPayload = { maxResourceSize: 0, maxArgumentsPerCF: 0, minAllowedDeduplicationIntervalInSecForCF: 0, + minAllowedAggregationIntervalInSecForCF: 0, minAllowedScheduledUpdateIntervalInSecForCF: 0, maxRelationLevelPerCfArgument: 0, maxDataPointsPerRollingArg: 0, diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 44fe9dcda7..9d192a0d14 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -225,6 +225,7 @@ additionalClass="tb-suffix-show-on-hover" appearance="outline" panelWidth="" + showInlineError required [label]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name': 'ai-models.model-id') | translate" [errorText]="(provider === aiProvider.AZURE_OPENAI ? 'ai-models.deployment-name-required': 'ai-models.model-id-required') | translate" diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts index 5e3a854aa2..e9bf146bfd 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.module.ts @@ -41,6 +41,9 @@ import { import { RelatedEntitiesAggregationComponentModule } from '@home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.module'; +import { + EntityAggregationComponentModule +} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module'; @NgModule({ declarations: [ @@ -56,6 +59,7 @@ import { SimpleConfigurationModule, PropagationConfigurationModule, RelatedEntitiesAggregationComponentModule, + EntityAggregationComponentModule, ], exports: [ CalculatedFieldDialogComponent, diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html index c7eeaa253b..9277bae767 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.html @@ -74,25 +74,27 @@ } -
-
{{ 'calculated-fields.argument-type' | translate }}
- - - @for (type of argumentTypes; track type) { - {{ ArgumentTypeTranslations.get(type) | translate }} + @if (!hiddenEntityKeyTypes) { +
+
{{ 'calculated-fields.argument-type' | translate }}
+ + + @for (type of argumentTypes; track type) { + {{ ArgumentTypeTranslations.get(type) | translate }} + } + + @if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { + + warning + } - - @if (refEntityKeyFormGroup.get('type').hasError('required') && refEntityKeyFormGroup.get('type').touched) { - - warning - - } - -
+
+
+ } @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Tenant) { @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Attribute) {
@@ -149,21 +151,23 @@ }"> } @if (refEntityKeyFormGroup.get('type').value !== ArgumentType.Rolling) { -
-
{{ 'calculated-fields.default-value' | translate }}
- - - @if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) { - - warning - - } - -
+ @if (!hiddenDefaultValue) { +
+
{{ 'calculated-fields.default-value' | translate }}
+ + + @if (argumentFormGroup.get('defaultValue').touched && argumentFormGroup.get('defaultValue').hasError('required')) { + + warning + + } + +
+ } } @else {
{{ 'calculated-fields.time-window' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts index 4e8abbe841..ad97e7b06e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-argument-panel.component.ts @@ -25,7 +25,7 @@ import { ViewChild } from '@angular/core'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { charsWithNumRegex, oneSpaceInsideRegex } from '@shared/models/regex.constants'; import { ArgumentEntityType, @@ -34,7 +34,10 @@ import { ArgumentType, ArgumentTypeTranslations, CalculatedFieldArgumentValue, - getCalculatedFieldCurrentEntityFilter + FORBIDDEN_NAMES, + forbiddenNamesValidator, + getCalculatedFieldCurrentEntityFilter, + uniqueNameValidator } from '@shared/models/calculated-field.models'; import { debounceTime, distinctUntilChanged, filter } from 'rxjs/operators'; import { EntityType } from '@shared/models/entity-type.models'; @@ -70,9 +73,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI @Input() usedArgumentNames: string[]; @Input() isOutputKey = false; @Input() hiddenEntityTypes = false; + @Input() hiddenEntityKeyTypes = false; + @Input() hiddenDefaultValue = false; @Input() defaultValueRequired = false; @Input() hint: string; @Input() predefinedEntityFilter: EntityFilter; + @Input() forbiddenNames = FORBIDDEN_NAMES; @Input() argumentEntityTypes = Object.values(ArgumentEntityType).filter(value => value !== ArgumentEntityType.RelationQuery) as ArgumentEntityType[]; @ViewChild('entityAutocomplete') entityAutocomplete: EntityAutocompleteComponent; @@ -85,7 +91,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI readonly defaultLimit = Math.floor(this.maxDataPointsPerRollingArg / 10); argumentFormGroup = this.fb.group({ - argumentName: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenArgumentNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + argumentName: ['', [Validators.required, Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], refEntityId: [null], refEntityKey: this.fb.group({ type: [ArgumentType.LatestTelemetry, [Validators.required]], @@ -140,6 +146,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } ngOnInit(): void { + this.updatedFormValidators(); this.updatedArgumentType(); this.argumentFormGroup.patchValue(this.argument, {emitEvent: false}); this.currentEntityFilter = getCalculatedFieldCurrentEntityFilter(this.entityName, this.entityId); @@ -168,7 +175,7 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI saveArgument(): void { const value = this.argumentFormGroup.value as CalculatedFieldArgumentValue; if (this.entityType === ArgumentEntityType.Owner) { - value.refDynamicSourceConfiguration.type = ArgumentEntityType.Owner; + value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner}; } else if (this.entityType === ArgumentEntityType.Tenant) { value.refEntityId = new TenantId(this.tenantId) as any; } @@ -186,6 +193,12 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI this.popover.hide(); } + private updatedFormValidators(): void { + this.argumentFormGroup.get('argumentName').addValidators( + [uniqueNameValidator(this.usedArgumentNames), forbiddenNamesValidator(this.forbiddenNames)]); + this.argumentFormGroup.get('argumentName').updateValueAndValidity({emitEvent: false}); + } + private updatedArgumentType(): void { let argumentType = ArgumentEntityType.Current; if (this.argument.refDynamicSourceConfiguration?.type === ArgumentEntityType.Owner) { @@ -264,15 +277,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI }); } - private uniqNameRequired(): ValidatorFn { - return (control: FormControl) => { - const newName = control.value.trim().toLowerCase(); - const isDuplicate = this.usedArgumentNames?.some(name => name.toLowerCase() === newName); - - return isDuplicate ? { duplicateName: true } : null; - }; - } - private observeEntityKeyChanges(): void { this.argumentFormGroup.get('refEntityKey').get('type').valueChanges .pipe(takeUntilDestroyed()) @@ -306,14 +310,6 @@ export class CalculatedFieldArgumentPanelComponent implements OnInit, AfterViewI } } - private forbiddenArgumentNameValidator(): ValidatorFn { - return (control: FormControl) => { - const trimmedValue = control.value.trim().toLowerCase(); - const forbiddenArgumentNames = ['ctx', 'e', 'pi', 'propagationCtx']; - return forbiddenArgumentNames.includes(trimmedValue) ? { forbiddenName: true } : null; - }; - } - private updatedRefEntityIdState(type: ArgumentEntityType, emitEvent = true): void { const isEntityWithId = !!type && ![ArgumentEntityType.Tenant, ArgumentEntityType.Current, ArgumentEntityType.Owner].includes(type); this.argumentFormGroup.get('refEntityId')[isEntityWithId ? 'enable' : 'disable']({emitEvent}); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts index cae0b92387..494d6f0159 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module.ts @@ -29,6 +29,9 @@ import { import { RelatedAggregationArgumentsTableComponent } from '@home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component'; +import { + EntityAggregationArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component'; @NgModule({ imports: [ @@ -39,12 +42,14 @@ import { CalculatedFieldArgumentPanelComponent, CalculatedFieldArgumentsTableComponent, PropagateArgumentsTableComponent, - RelatedAggregationArgumentsTableComponent + RelatedAggregationArgumentsTableComponent, + EntityAggregationArgumentsTableComponent, ], exports: [ CalculatedFieldArgumentsTableComponent, PropagateArgumentsTableComponent, - RelatedAggregationArgumentsTableComponent + RelatedAggregationArgumentsTableComponent, + EntityAggregationArgumentsTableComponent, ] }) export class CalculatedFieldArgumentsTableModule {} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts new file mode 100644 index 0000000000..6d9b746d59 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts @@ -0,0 +1,71 @@ +/// +/// 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 { ChangeDetectorRef, Component, DestroyRef, forwardRef, Renderer2, ViewContainerRef, } from '@angular/core'; +import { FormBuilder, NG_VALIDATORS, NG_VALUE_ACCESSOR, } from '@angular/forms'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { EntityService } from '@core/http/entity.service'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { + CalculatedFieldArgumentsTableComponent +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; +import { ArgumentEntityType } from '@shared/models/calculated-field.models'; + +@Component({ + selector: 'tb-entity-aggregation-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), + multi: true + } + ], +}) +export class EntityAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { + + constructor( + protected fb: FormBuilder, + protected popoverService: TbPopoverService, + protected viewContainerRef: ViewContainerRef, + protected cd: ChangeDetectorRef, + protected renderer: Renderer2, + protected entityService: EntityService, + protected destroyRef: DestroyRef, + protected store: Store + ) { + super(fb, popoverService, viewContainerRef, cd, renderer, entityService, destroyRef, store); + + this.argumentNameColumn = 'calculated-fields.argument-name'; + this.displayColumns = ['name', 'type', 'key', 'actions']; + this.panelAdditionalCtx = { + hiddenEntityTypes: true, + argumentEntityTypes: [ArgumentEntityType.Current], + hint: 'calculated-fields.entity-aggregation.argument-setting-hint', + hiddenDefaultValue: true, + hiddenEntityKeyTypes: true, + }; + + this.isScript = false; + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts index 04d1dbf91b..94d94c2d76 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts @@ -31,8 +31,13 @@ import { AppState } from '@core/core.state'; import { CalculatedFieldArgumentsTableComponent } from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.component'; -import { ArgumentEntityType, ArgumentType, CalculatedFieldArgumentValue } from '@shared/models/calculated-field.models'; -import { isDefined } from '@core/utils'; +import { + ArgumentEntityType, + ArgumentType, + CalculatedFieldArgumentValue, + FORBIDDEN_NAMES +} from '@shared/models/calculated-field.models'; +import { isDefined, isUndefinedOrNull } from '@core/utils'; import { NULL_UUID } from '@shared/models/id/has-uuid'; @Component({ @@ -88,20 +93,21 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa this.displayColumns = ['name', 'type', 'key', 'actions']; this.panelAdditionalCtx = { argumentEntityTypes: [ArgumentEntityType.Current], - isOutputKey: true + isOutputKey: true, + forbiddenNames: [...FORBIDDEN_NAMES, 'propagationCtx'], }; } } protected isEditButtonShowBadge(argument: CalculatedFieldArgumentValue): boolean { - if (!this.isScript && isDefined(argument?.refEntityId)) { + if (!this.isScript && (isDefined(argument?.refEntityId) || isDefined(argument?.refDynamicSourceConfiguration))) { return false; } return super.isEditButtonShowBadge(argument); } protected updateErrorText(): void { - if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId))) { + if (!this.isScript && this.argumentsFormArray.controls.some(control => isDefined(control.value?.refEntityId) || isDefined(control.value.refDynamicSourceConfiguration))) { this.errorText = 'calculated-fields.hint.arguments-propagate-argument-entity-type'; } else if (!this.isScript && this.argumentsFormArray.controls.some(control => control.value.refEntityKey.type === ArgumentType.Rolling)) { this.errorText = 'calculated-fields.hint.arguments-propagate-arguments-with-rolling'; @@ -109,6 +115,8 @@ export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTa this.errorText = 'calculated-fields.hint.arguments-entity-not-found'; } else if (!this.argumentsFormArray.controls.length) { this.errorText = 'calculated-fields.hint.arguments-empty'; + } if (this.isScript && !this.argumentsFormArray.controls.some(control => isUndefinedOrNull(control.value?.refEntityId) && isUndefinedOrNull(control.value.refDynamicSourceConfiguration))) { + this.errorText = 'calculated-fields.hint.arguments-propagate-argument-must-current-entity'; } else { this.errorText = ''; } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts index dccab5cfd5..70bfa43c63 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/debug-dialog/calculated-field-debug-dialog.component.ts @@ -58,7 +58,11 @@ export class CalculatedFieldDebugDialogComponent extends DialogComponent this.data.value.type === CalculatedFieldType.SCRIPT && !!(event as Event).body.arguments); + this.eventsTable.entitiesTable.cellActionDescriptors[0].isEnabled = (event => { + return (this.data.value.type === CalculatedFieldType.SCRIPT || + (this.data.value.type === CalculatedFieldType.PROPAGATION && this.data.value.configuration.applyExpressionToResolvedArguments) + ) && !!(event as Event).body.arguments + }); this.eventsTable.entitiesTable.updateData(); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index 08bf1328d8..7184a5f098 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -79,6 +79,7 @@ [entityId]="data.entityId" [entityName]="data.entityName" [tenantId]="data.tenantId" + [ownerId]="data.ownerId" [testScript]="onTestScript.bind(this)" > } @@ -89,6 +90,13 @@ [tenantId]="data.tenantId" > } + @case (CalculatedFieldType.ENTITY_AGGREGATION) { + + } @default { +
+
+
+ {{ 'calculated-fields.arguments' | translate }} +
+
+ {{ 'calculated-fields.entity-aggregation.argument-hint' | translate }} +
+ +
+
+
+ {{ 'calculated-fields.metrics.metrics' | translate }} +
+ +
+
+
+ {{ 'calculated-fields.entity-aggregation.aggregation-interval' | translate }} +
+ +
+ + calculated-fields.aggregate-interval-type + + + {{ AggIntervalTypeTranslations.get(type) | translate }} + + + + + +
+ @if (entityAggregationConfiguration.get('interval.type').value === AggIntervalType.CUSTOM) { + + + } +
+ +
+ {{ 'calculated-fields.entity-aggregation.apply-offset' | translate }} +
+
+ @if (entityAggregationConfiguration.get('interval.allowOffsetSec').value) { + + +
+ {{ hint }} +
+ } +
+
+
+ +
+ {{ 'calculated-fields.entity-aggregation.wait-delay' | translate }} +
+
+ @if (entityAggregationConfiguration.get('allowWatermark').value) { + + + + + } +
+
+ +
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts new file mode 100644 index 0000000000..5d612704a0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts @@ -0,0 +1,411 @@ +/// +/// 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 { Component, forwardRef, Input } from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityId } from '@shared/models/id/entity-id'; +import { + AggInterval, + AggIntervalType, + AggIntervalTypeTranslations, + CalculatedFieldEntityAggregationConfiguration, + CalculatedFieldOutput, + CalculatedFieldType, + notEmptyObjectValidator, + OutputType +} from '@shared/models/calculated-field.models'; +import { filter, map } from 'rxjs/operators'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { AVG_MONTH, AVG_QUARTER, DAY, HOUR, MINUTE, SECOND, YEAR } from '@shared/models/time/time.models'; +import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { merge } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; +import _moment from 'moment'; + +interface CalculatedFieldEntityAggregationConfigurationValue extends CalculatedFieldEntityAggregationConfiguration { + interval: AggInterval & {allowOffsetSec?: boolean}; + allowWatermark: boolean; +} + +enum TimeCategory { + SECONDS = 'SECONDS', + MINUTES = 'MINUTES', + HOURS = 'HOURS', + DAYS = 'DAYS' +} + +@Component({ + selector: 'tb-entity-aggregation-component', + templateUrl: './entity-aggregation-component.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAggregationComponentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityAggregationComponentComponent), + multi: true + } + ], +}) +export class EntityAggregationComponentComponent implements ControlValueAccessor, Validator { + + @Input({required: true}) + entityId: EntityId; + + @Input({required: true}) + tenantId: string; + + @Input({required: true}) + entityName: string; + + readonly minAllowedAggregationIntervalInSecForCF = getCurrentAuthState(this.store).minAllowedAggregationIntervalInSecForCF; + readonly DayInSec = DAY / SECOND; + + entityAggregationConfiguration = this.fb.group({ + arguments: this.fb.control({}, notEmptyObjectValidator()), + metrics: this.fb.control({}, notEmptyObjectValidator()), + interval: this.fb.group({ + type: [AggIntervalType.HOUR], + tz: ['', Validators.required], + durationSec: [this.minAllowedAggregationIntervalInSecForCF, Validators.required], + allowOffsetSec: [false], + offsetSec: [this.minAllowedAggregationIntervalInSecForCF > 60 ? MINUTE / SECOND : 1, Validators.required], + }), + allowWatermark: [false], + watermark: this.fb.group({ + duration: [HOUR/SECOND, Validators.required], + }), + output: this.fb.control({ + type: OutputType.Timeseries, + }), + }); + + arguments$ = this.entityAggregationConfiguration.get('arguments').valueChanges.pipe( + map(argumentsObj => Object.keys(argumentsObj)) + ); + + AggIntervalType = AggIntervalType; + AggIntervalTypes = Object.values(AggIntervalType) as AggIntervalType[]; + AggIntervalTypeTranslations = AggIntervalTypeTranslations; + + hint: string; + + private propagateChange: (config: CalculatedFieldEntityAggregationConfiguration) => void = () => { }; + + constructor(private fb: FormBuilder, + private store: Store, + private translate: TranslateService,) { + + this.entityAggregationConfiguration.get('interval.type').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((type: AggIntervalType) => { + this.checkAggIntervalType(type); + }); + + this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((allow: boolean) => { + this.checkIntervalDuration(allow); + }); + + this.entityAggregationConfiguration.get('allowWatermark').valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((allow: boolean) => { + this.checkWatermark(allow); + }); + + merge( + this.entityAggregationConfiguration.get('interval.type').valueChanges, + this.entityAggregationConfiguration.get('interval.durationSec').valueChanges, + this.entityAggregationConfiguration.get('interval.offsetSec').valueChanges, + this.entityAggregationConfiguration.get('interval.allowOffsetSec').valueChanges, + ).pipe( + filter(() => this.entityAggregationConfiguration.get('interval.allowOffsetSec').value), + takeUntilDestroyed() + ).subscribe(() => { + this.updatedOffsetHint(); + }); + + this.entityAggregationConfiguration.valueChanges.pipe( + takeUntilDestroyed() + ).subscribe((value: CalculatedFieldEntityAggregationConfigurationValue) => { + this.updatedModel(deepClone(value)); + }); + } + + validate(): ValidationErrors | null { + return this.entityAggregationConfiguration.valid || this.entityAggregationConfiguration.disabled ? null : {invalidPropagateConfig: false}; + } + + writeValue(value: CalculatedFieldEntityAggregationConfiguration): void { + const data: CalculatedFieldEntityAggregationConfigurationValue = { + ...value, + allowWatermark: isDefinedAndNotNull(value.watermark), + interval: {...value.interval, allowOffsetSec: isDefinedAndNotNull(value?.interval?.offsetSec)} + } + this.entityAggregationConfiguration.patchValue(data, {emitEvent: false}); + this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); + this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); + this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); + this.updatedOffsetHint(); + setTimeout(() => { + this.entityAggregationConfiguration.get('arguments').updateValueAndValidity({onlySelf: true}); + }); + } + + registerOnChange(fn: (config: CalculatedFieldEntityAggregationConfiguration) => void): void { + this.propagateChange = fn; + } + + registerOnTouched(_: any): void { } + + setDisabledState(isDisabled: boolean): void { + if (isDisabled) { + this.entityAggregationConfiguration.disable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.enable({emitEvent: false}); + this.checkAggIntervalType(this.entityAggregationConfiguration.get('interval.type').value); + this.checkIntervalDuration(this.entityAggregationConfiguration.get('interval.allowOffsetSec').value); + this.checkWatermark(this.entityAggregationConfiguration.get('allowWatermark').value); + } + } + + get maxOffsetTime(): number { + switch (this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType) { + case AggIntervalType.HOUR: + return HOUR / SECOND - 1; + case AggIntervalType.DAY: + return DAY / SECOND - 1; + case AggIntervalType.WEEK: + case AggIntervalType.WEEK_SUN_SAT: + return 7 * DAY / SECOND - 1; + case AggIntervalType.MONTH: + return AVG_MONTH / SECOND; + case AggIntervalType.QUARTER: + return AVG_QUARTER / SECOND - 1; + case AggIntervalType.YEAR: + return YEAR / SECOND - 1; + case AggIntervalType.CUSTOM: + return this.entityAggregationConfiguration.get('interval.durationSec').value - 1; + } + } + + private updatedModel(value: CalculatedFieldEntityAggregationConfigurationValue): void { + value.type = CalculatedFieldType.ENTITY_AGGREGATION; + if (!value.interval.allowOffsetSec) { + delete value.interval.offsetSec; + } + delete value.interval.allowOffsetSec; + if (!value.allowWatermark) { + delete value.watermark; + } + delete value.allowWatermark; + this.propagateChange(value); + } + + private checkAggIntervalType(type: AggIntervalType) { + if (type === AggIntervalType.CUSTOM) { + this.entityAggregationConfiguration.get('interval.durationSec').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('interval.durationSec').disable({emitEvent: false}); + } + } + + private checkIntervalDuration(allow: boolean) { + if (allow) { + this.entityAggregationConfiguration.get('interval.offsetSec').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('interval.offsetSec').disable({emitEvent: false}); + this.hint = ''; + } + } + + private checkWatermark(allow: boolean) { + if (allow) { + this.entityAggregationConfiguration.get('watermark').enable({emitEvent: false}); + } else { + this.entityAggregationConfiguration.get('watermark').disable({emitEvent: false}); + } + } + + private updatedOffsetHint(): void { + const offset = this.entityAggregationConfiguration.get('interval.offsetSec').value; + const intervalType = this.entityAggregationConfiguration.get('interval.type').value as AggIntervalType; + const durationSec = this.entityAggregationConfiguration.get('interval.durationSec').value; + const offsetCategory = this.getTimeCategory(offset); + const now = _moment.utc(); + let interval: string = ''; + if (intervalType === AggIntervalType.CUSTOM) { + const durationSecCategory = this.getTimeCategory(durationSec); + const formatString = this.getCustomFormatString(offsetCategory, durationSecCategory); + const intervals: string[] = []; + let allInterval = durationSec >= HOUR*6/SECOND && durationSec < DAY/SECOND; + now.startOf('year').add(offset, 'seconds'); + + let repeat = 2; + if (allInterval) { + repeat = Math.floor(DAY/SECOND/durationSec); + if (repeat > 4) { + repeat = 2; + allInterval = false; + } + } + + for (let i = 0; i < repeat; i++) { + const s1 = now.clone().add(i * durationSec, 'seconds').format(formatString); + const s2 = now.clone().add((i + 1) * durationSec, 'seconds').format(formatString); + intervals.push(`${s1} - ${s2}`); + } + interval = intervals.join('; '); + + if (allInterval) { + this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset', {interval}); + } else { + interval += '…' + this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', {interval}); + } + } else { + interval = this.buildStandardIntervalString(now, intervalType, offset, offsetCategory); + this.hint = this.translate.instant('calculated-fields.aggregate-period-hint-offset-and-so-on', { interval }); + } + } + + private getTimeCategory(seconds: number): TimeCategory { + if (seconds % (DAY / SECOND) === 0) { + return TimeCategory.DAYS; + } + if (seconds % (HOUR / SECOND) === 0) { + return TimeCategory.HOURS; + } + if (seconds % (MINUTE / SECOND) === 0) { + return TimeCategory.MINUTES; + } + return TimeCategory.SECONDS; + } + + private getCustomFormatString(offsetCat: TimeCategory, durationCat: TimeCategory): string { + if (durationCat === TimeCategory.DAYS) { + if (offsetCat === TimeCategory.SECONDS) { + return '[Day] D, HH:mm:ss'; + } + if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) { + return '[Day] D, HH:mm'; + } + return '[Day] D'; + } else { + if (offsetCat === TimeCategory.SECONDS) { + return 'HH:mm:ss'; + } + return 'HH:mm'; + } + } + + private formatAdditiveInterval(now: _moment.Moment, addUnit: 'hour' | 'day' | 'month' | 'quarter', offsetCat: TimeCategory, + formats: { [key in TimeCategory]?: { s1: string, s2: string, s3: string } }): string { + const formatTs = formats[offsetCat] || formats[TimeCategory.SECONDS]; + + if (!formatTs) { + return ''; + } + + const s1 = now.format(formatTs.s1); + const s2 = now.clone().add(1, addUnit).format(formatTs.s2); + const s3 = now.clone().add(2, addUnit).format(formatTs.s3); + + return `${s1} - ${s2}; ${s2} - ${s3}…`; + } + + private formatNextInterval(now: _moment.Moment, offsetCat: TimeCategory, secFmt: string, minHourFmt: string, dayFmt: string): string { + let s1: string; + if (offsetCat === TimeCategory.SECONDS) { + s1 = now.format(secFmt); + } else if (offsetCat === TimeCategory.MINUTES || offsetCat === TimeCategory.HOURS) { + s1 = now.format(minHourFmt); + } else { + s1 = now.format(dayFmt); + } + + const s2 = `Next ${s1}`; + const s3 = `Following ${s1}`; + return `${s1} - ${s2}; ${s2} - ${s3}… `; + } + + private buildStandardIntervalString(now: _moment.Moment, type: AggIntervalType, offset: number, offsetCat: TimeCategory): string { + switch (type) { + case AggIntervalType.HOUR: + now.startOf('day').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'hour', offsetCat, { + [TimeCategory.SECONDS]: { s1: 'HH:mm:ss', s2: 'HH:mm:ss', s3: 'HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: 'HH:mm:ss', s2: 'HH:mm', s3: 'HH:mm' } + }); + + case AggIntervalType.DAY: + now.startOf('month').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'day', offsetCat, { + [TimeCategory.SECONDS]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm:ss', s3: '[Day] D, HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: '[Day] D, HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' }, + [TimeCategory.HOURS]: { s1: 'HH:mm:ss', s2: '[Day] D, HH:mm', s3: '[Day] D, HH:mm' } // Note: Original logic, s1 format is different + }); + + case AggIntervalType.WEEK: + now.isoWeekday(1).startOf('isoWeek').add(offset, 'seconds'); + return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd'); + + case AggIntervalType.WEEK_SUN_SAT: + now.startOf('week').add(offset, 'seconds'); + return this.formatNextInterval(now, offsetCat, 'ddd, HH:mm:ss', 'ddd, HH:mm', 'ddd'); + + case AggIntervalType.MONTH: + now.startOf('year').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'month', offsetCat, { + [TimeCategory.SECONDS]: { s1: 'Do [of month], HH:mm:ss', s2: '[Next] Do, HH:mm:ss', s3: '[Following] Do, HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' }, + [TimeCategory.HOURS]: { s1: 'Do [of month], HH:mm', s2: '[Next] Do, HH:mm', s3: '[Following] Do, HH:mm' }, + [TimeCategory.DAYS]: { s1: 'Do [of month]', s2: '[Next] Do', s3: '[Following] Do' } + }); + + case AggIntervalType.QUARTER: + now.startOf('year').add(offset, 'seconds'); + return this.formatAdditiveInterval(now, 'quarter', offsetCat, { + [TimeCategory.SECONDS]: { s1: 'MMM Do, HH:mm:ss', s2: 'MMM Do, HH:mm:ss', s3: 'MMM Do, HH:mm:ss' }, + [TimeCategory.MINUTES]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' }, + [TimeCategory.HOURS]: { s1: 'MMM Do, HH:mm', s2: 'MMM Do, HH:mm', s3: 'MMM Do, HH:mm' }, + [TimeCategory.DAYS]: { s1: 'MMM Do', s2: 'MMM Do', s3: 'MMM Do' } + }); + + case AggIntervalType.YEAR: + now.startOf('year').add(offset, 'seconds'); + return this.formatNextInterval(now, offsetCat, 'MMM Do, HH:mm:ss', 'MMM Do, HH:mm', 'MMM Do'); + + default: + return ''; + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts new file mode 100644 index 0000000000..44bbbc7237 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.module.ts @@ -0,0 +1,49 @@ +/// +/// 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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SharedModule } from '@shared/shared.module'; +import { + CalculatedFieldOutputModule +} from '@home/components/calculated-fields/components/output/calculated-field-output.module'; +import { + CalculatedFieldArgumentsTableModule +} from '@home/components/calculated-fields/components/calculated-field-arguments/calculated-field-arguments-table.module'; +import { + EntityAggregationComponentComponent +} from '@home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component'; +import { + CalculatedFieldMetricsTableModule +} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.module'; + +@NgModule({ + imports: [ + CommonModule, + SharedModule, + CalculatedFieldOutputModule, + CalculatedFieldArgumentsTableModule, + CalculatedFieldMetricsTableModule, + ], + declarations: [ + EntityAggregationComponentComponent, + ], + exports: [ + EntityAggregationComponentComponent, + ] +}) +export class EntityAggregationComponentModule { +} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html index 250d6bd120..1f5444f8b3 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -176,7 +176,7 @@
- @if (entityFilter.singleEntity?.id) { + @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.RelationQuery || entityType === ArgumentEntityType.Current) {
{{ 'calculated-fields.perimeter-attribute-key' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts index 0e18521572..5f3e00a0cf 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts @@ -32,11 +32,14 @@ import { ArgumentEntityTypeTranslations, CalculatedFieldGeofencing, CalculatedFieldGeofencingValue, + FORBIDDEN_NAMES, + forbiddenNamesValidator, GeofencingDirectionLevelTranslations, GeofencingDirectionTranslations, GeofencingReportStrategy, GeofencingReportStrategyTranslations, - getCalculatedFieldCurrentEntityFilter + getCalculatedFieldCurrentEntityFilter, + uniqueNameValidator } from '@shared/models/calculated-field.models'; import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators'; import { EntityType } from '@shared/models/entity-type.models'; @@ -76,7 +79,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit readonly maxRelationLevelPerCfArgument = getCurrentAuthState(this.store).maxRelationLevelPerCfArgument; geofencingFormGroup = this.fb.group({ - name: ['', [Validators.required, this.uniqNameRequired(), this.forbiddenNameValidator(), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], + name: ['', [Validators.required, forbiddenNamesValidator(FORBIDDEN_NAMES), Validators.pattern(charsWithNumRegex), Validators.maxLength(255)]], refEntityId: this.fb.group({ entityType: [ArgumentEntityType.Current], id: [''] @@ -133,6 +136,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit } ngOnInit(): void { + this.updatedFormValidators(); this.geofencingFormGroup.patchValue(this.zone, {emitEvent: false}); if (this.zone.refDynamicSourceConfiguration?.type) { this.refEntityIdFormGroup.get('entityType').setValue(this.zone.refDynamicSourceConfiguration.type, {emitEvent: false}); @@ -159,6 +163,11 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit return of(['Contains', 'Manages']).pipe(map(name => name?.filter(option => option.toLowerCase().includes(search)))); } + private updatedFormValidators(): void { + this.geofencingFormGroup.get('name').addValidators(uniqueNameValidator(this.usedNames)); + this.geofencingFormGroup.get('name').updateValueAndValidity({emitEvent: false}); + } + private observeCreateRelationZonesChanges(): void { this.geofencingFormGroup.get('createRelationsWithMatchedZones').valueChanges .pipe(takeUntilDestroyed()) @@ -198,8 +207,7 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit break; case ArgumentEntityType.Owner: delete value.refEntityId; - value.refDynamicSourceConfiguration ||= { type: ArgumentEntityType.Owner }; - value.refDynamicSourceConfiguration.type = ArgumentEntityType.Owner; + value.refDynamicSourceConfiguration = {type: ArgumentEntityType.Owner}; break; case ArgumentEntityType.RelationQuery: delete value.refEntityId; @@ -276,29 +284,12 @@ export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit }); } - private uniqNameRequired(): ValidatorFn { - return (control: FormControl) => { - const newName = control.value.trim().toLowerCase(); - const isDuplicate = this.usedNames?.some(name => name.toLowerCase() === newName); - - return isDuplicate ? { duplicateName: true } : null; - }; - } - private levelsRequired(): ValidatorFn { return (control: FormControl) => { return control.value.length ? null : { levelsRequired: true }; }; } - private forbiddenNameValidator(): ValidatorFn { - return (control: FormControl) => { - const trimmedValue = control.value.trim().toLowerCase(); - const forbiddenNames = ['ctx', 'e', 'pi']; - return forbiddenNames.includes(trimmedValue) ? { forbiddenName: true } : null; - }; - } - levelsFormArray(): UntypedFormArray { return this.refDynamicSourceFormGroup.get('levels') as UntypedFormArray; } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html similarity index 70% rename from ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html rename to ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html index 8b29689b7f..eda8a779ad 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/calculated-field-metrics-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html @@ -77,48 +77,52 @@
-
- - - - -
- {{ 'calculated-fields.metrics.filter' | translate }} + @if (!simpleMode) { +
+ + + + +
+ {{ 'calculated-fields.metrics.filter' | translate }} +
+
+
+
+ + +
{{ 'api-usage.tbel' | translate }}
- - - - - -
{{ 'api-usage.tbel' | translate }} -
-
-
-
-
- -
-
{{ 'calculated-fields.metrics.value-source' | translate }}
- - - @for (inputType of AggInputTypes; track inputType) { - {{ AggInputTypeTranslations.get(inputType) | translate }} - } - - + + +
+ } + + @if (!simpleMode) { +
+
{{ 'calculated-fields.metrics.value-source' | translate }}
+ + + @for (inputType of AggInputTypes; track inputType) { + {{ AggInputTypeTranslations.get(inputType) | translate }} + } + + +
+ } @if (this.metricForm.get('input.type').value === AggInputType.key) {
{{ 'calculated-fields.argument-name' | translate }}
@@ -156,6 +160,14 @@ } + @if (simpleMode) { +
+
{{ 'calculated-fields.default-value' | translate }}
+ + + +
+ }