committed by
GitHub
107 changed files with 3797 additions and 274 deletions
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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<AggIntervalEntry, AggIntervalEntryStatus> aggIntervals; |
||||
|
|
||||
|
private boolean forceResetPrevious; |
||||
|
|
||||
|
public EntityAggregationArgumentEntry(Map<AggIntervalEntry, AggIntervalEntryStatus> 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, AggIntervalEntryStatus> 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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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<String, AggMetric> 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<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception { |
||||
|
createIntervalIfNotExist(); |
||||
|
long now = System.currentTimeMillis(); |
||||
|
|
||||
|
Map<AggIntervalEntry, Map<String, ArgumentEntry>> results = new HashMap<>(); |
||||
|
List<AggIntervalEntry> 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<AggIntervalEntry> 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<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> 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<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> getIntervals() { |
||||
|
Map<AggIntervalEntry, Map<String, AggIntervalEntryStatus>> 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<String, AggIntervalEntryStatus> args, |
||||
|
List<AggIntervalEntry> expiredIntervals, |
||||
|
Map<AggIntervalEntry, Map<String, ArgumentEntry>> 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<String, AggIntervalEntryStatus> args, |
||||
|
Map<AggIntervalEntry, Map<String, ArgumentEntry>> 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<String, AggIntervalEntryStatus> args, |
||||
|
Map<AggIntervalEntry, Map<String, ArgumentEntry>> 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<AggIntervalEntry, Map<String, ArgumentEntry>> 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<AggIntervalEntry, Map<String, ArgumentEntry>> results, Integer precision) { |
||||
|
ArrayNode result = JacksonUtil.newArrayNode(); |
||||
|
results.forEach((interval, args) -> { |
||||
|
ObjectNode metricsNode = JacksonUtil.newObjectNode(); |
||||
|
for (Map.Entry<String, ArgumentEntry> 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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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<String, Argument> arguments = new HashMap<>(); |
||||
|
Argument argument = new Argument(); |
||||
|
argument.setRefEntityKey(new ReferencedEntityKey("energy", ArgumentType.TS_LATEST, null)); |
||||
|
arguments.put("en", argument); |
||||
|
|
||||
|
Map<String, AggMetric> 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<String, Argument> inputs, |
||||
|
Map<String, AggMetric> 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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
|
||||
@ -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<String, Argument> arguments; |
||||
|
@Valid |
||||
|
@NotEmpty |
||||
|
private Map<String, AggMetric> 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(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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(); |
||||
|
|
||||
|
} |
||||
@ -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 |
||||
|
|
||||
|
} |
||||
@ -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."); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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; |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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<AggInterval> 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<AggInterval> 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<Arguments> intervals() { |
||||
|
return Stream.of( |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset)), |
||||
|
Arguments.of((LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4))) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@MethodSource("nextIntervalFromExactDate") |
||||
|
void testNextIntervalFromExactDate(LongFunction<AggInterval> intervalCreator, Function<ZonedDateTime, ZonedDateTime> 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<Arguments> nextIntervalFromExactDate() { |
||||
|
return Stream.of( |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new HourInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusHours(1) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new DayInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusDays(1) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new WeekInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusWeeks(1) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new WeekSunSatInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusWeeks(1) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new MonthInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusMonths(1) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new QuarterInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusMonths(3) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new YearInterval(TZ, offset), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusYears(1) |
||||
|
), |
||||
|
Arguments.of( |
||||
|
(LongFunction<AggInterval>) offset -> new CustomInterval(TZ, offset, TimeUnit.HOURS.toSeconds(4)), |
||||
|
(Function<ZonedDateTime, ZonedDateTime>) currentInterval -> currentInterval.plusHours(4) |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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<AppState> |
||||
|
) { |
||||
|
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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,133 @@ |
|||||
|
<!-- |
||||
|
|
||||
|
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. |
||||
|
|
||||
|
--> |
||||
|
<div [formGroup]="entityAggregationConfiguration" class="tb-form-panel no-border no-padding"> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title"> |
||||
|
{{ 'calculated-fields.arguments' | translate }} |
||||
|
</div> |
||||
|
<div class="tb-form-hint tb-primary-fill hint-container"> |
||||
|
{{ 'calculated-fields.entity-aggregation.argument-hint' | translate }} |
||||
|
</div> |
||||
|
<tb-entity-aggregation-arguments-table formControlName="arguments" |
||||
|
[entityId]="entityId" |
||||
|
[tenantId]="tenantId" |
||||
|
[entityName]="entityName"/> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" tb-hint-tooltip-icon="{{ 'calculated-fields.hint.metrics' | translate }}"> |
||||
|
{{ 'calculated-fields.metrics.metrics' | translate }} |
||||
|
</div> |
||||
|
<tb-calculated-field-metrics-table formControlName="metrics" |
||||
|
simpleMode |
||||
|
[arguments]="arguments$ | async" |
||||
|
></tb-calculated-field-metrics-table> |
||||
|
</div> |
||||
|
<div class="tb-form-panel"> |
||||
|
<div class="tb-form-panel-title" tbTruncateWithTooltip tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.aggregation-interval-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.entity-aggregation.aggregation-interval' | translate }} |
||||
|
</div> |
||||
|
<ng-container formGroupName="interval"> |
||||
|
<div class="flex items-start gap-3 xs:flex-col xs:items-stretch"> |
||||
|
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
||||
|
<mat-label translate>calculated-fields.aggregate-interval-type</mat-label> |
||||
|
<mat-select formControlName="type" required> |
||||
|
<mat-option *ngFor="let type of AggIntervalTypes" [value]="type"> |
||||
|
{{ AggIntervalTypeTranslations.get(type) | translate }} |
||||
|
</mat-option> |
||||
|
</mat-select> |
||||
|
</mat-form-field> |
||||
|
<tb-timezone-select |
||||
|
class="flex-1" |
||||
|
appearance="outline" |
||||
|
subscriptSizing="dynamic" |
||||
|
required |
||||
|
userTimezoneByDefault |
||||
|
formControlName="tz"> |
||||
|
</tb-timezone-select> |
||||
|
</div> |
||||
|
@if (entityAggregationConfiguration.get('interval.type').value === AggIntervalType.CUSTOM) { |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="minAllowedAggregationIntervalInSecForCF" |
||||
|
[stepMultipleOf]="DayInSec" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
subscriptSizing="dynamic" |
||||
|
containerClass="flex gap-3" |
||||
|
labelText="{{ 'calculated-fields.aggregate-interval-value' | translate }}" |
||||
|
minErrorText="{{ 'calculated-fields.aggregate-interval-value-min' | translate : {sec: minAllowedAggregationIntervalInSecForCF} }}" |
||||
|
requiredText="{{ 'calculated-fields.aggregate-interval-value-required' | translate }}" |
||||
|
stepMultipleOfErrorText="{{ 'calculated-fields.aggregate-interval-value-step-multiple-of' | translate }}" |
||||
|
formControlName="durationSec"> |
||||
|
</tb-time-unit-input> |
||||
|
} |
||||
|
<div class="tb-form-panel stroked"> |
||||
|
<mat-slide-toggle class="mat-slide flex" formControlName="allowOffsetSec"> |
||||
|
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.apply-offset-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.entity-aggregation.apply-offset' | translate }} |
||||
|
</div> |
||||
|
</mat-slide-toggle> |
||||
|
@if (entityAggregationConfiguration.get('interval.allowOffsetSec').value) { |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="0" |
||||
|
[maxTime]="maxOffsetTime" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
subscriptSizing="dynamic" |
||||
|
containerClass="flex gap-3" |
||||
|
labelText="{{ 'calculated-fields.entity-aggregation.offset-value' | translate }}" |
||||
|
minErrorText="{{ 'calculated-fields.entity-aggregation.offset-value-min' | translate }}" |
||||
|
maxErrorText="{{ 'calculated-fields.entity-aggregation.offset-value-max' | translate }}" |
||||
|
requiredText="{{ 'calculated-fields.entity-aggregation.offset-value-required' | translate }}" |
||||
|
formControlName="offsetSec"> |
||||
|
</tb-time-unit-input> |
||||
|
<div class="tb-form-hint tb-primary-fill hint-container"> |
||||
|
{{ hint }} |
||||
|
</div> |
||||
|
} |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
<div class="tb-form-panel stroked"> |
||||
|
<mat-slide-toggle class="mat-slide flex" formControlName="allowWatermark"> |
||||
|
<div tb-hint-tooltip-icon="{{ 'calculated-fields.entity-aggregation.wait-delay-hint' | translate }}"> |
||||
|
{{ 'calculated-fields.entity-aggregation.wait-delay' | translate }} |
||||
|
</div> |
||||
|
</mat-slide-toggle> |
||||
|
@if (entityAggregationConfiguration.get('allowWatermark').value) { |
||||
|
<ng-container formGroupName="watermark"> |
||||
|
<tb-time-unit-input required |
||||
|
[minTime]="60" |
||||
|
sameWidthInputs |
||||
|
appearance="outline" |
||||
|
containerClass="flex gap-3" |
||||
|
labelText="{{ 'calculated-fields.entity-aggregation.duration' | translate }}" |
||||
|
minErrorText="{{ 'calculated-fields.entity-aggregation.duration-min' | translate }}" |
||||
|
hintText="{{ 'calculated-fields.entity-aggregation.duration-hint' | translate }}" |
||||
|
requiredText="{{ 'calculated-fields.entity-aggregation.duration-required' | translate }}" |
||||
|
formControlName="duration"> |
||||
|
</tb-time-unit-input> |
||||
|
</ng-container> |
||||
|
} |
||||
|
</div> |
||||
|
</div> |
||||
|
<tb-calculate-field-output formControlName="output" |
||||
|
containerInputClass="grid items-start grid-cols-2 gap-3 xs:grid-cols-1" |
||||
|
simpleMode |
||||
|
hiddenName |
||||
|
disableType |
||||
|
[entityId]="entityId"></tb-calculate-field-output> |
||||
|
</div> |
||||
@ -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<CalculatedFieldOutput>({ |
||||
|
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<AppState>, |
||||
|
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 ''; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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 { |
||||
|
} |
||||
@ -0,0 +1,42 @@ |
|||||
|
///
|
||||
|
/// 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 { |
||||
|
CalculatedFieldMetricsTableComponent |
||||
|
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component'; |
||||
|
import { |
||||
|
CalculatedFieldMetricsPanelComponent |
||||
|
} from '@home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component'; |
||||
|
|
||||
|
@NgModule({ |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
SharedModule, |
||||
|
], |
||||
|
declarations: [ |
||||
|
CalculatedFieldMetricsTableComponent, |
||||
|
CalculatedFieldMetricsPanelComponent |
||||
|
], |
||||
|
exports: [ |
||||
|
CalculatedFieldMetricsTableComponent |
||||
|
] |
||||
|
}) |
||||
|
export class CalculatedFieldMetricsTableModule { |
||||
|
|
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue