committed by
GitHub
366 changed files with 19713 additions and 2941 deletions
@ -0,0 +1,41 @@ |
|||
/** |
|||
* 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.actors.calculatedField; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.audit.ActionType; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.MsgType; |
|||
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
|
|||
@Data |
|||
@Builder |
|||
public class CalculatedFieldAlarmActionMsg implements ToCalculatedFieldSystemMsg { |
|||
|
|||
private final TenantId tenantId; |
|||
private final Alarm alarm; |
|||
private final ActionType action; |
|||
private final TbCallback callback; |
|||
|
|||
@Override |
|||
public MsgType getMsgType() { |
|||
return MsgType.CF_ALARM_ACTION_MSG; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,37 @@ |
|||
/** |
|||
* 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.actors.calculatedField; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.MsgType; |
|||
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
|
|||
@Data |
|||
public class CalculatedFieldArgumentResetMsg implements ToCalculatedFieldSystemMsg { |
|||
|
|||
private final TenantId tenantId; |
|||
private final CalculatedFieldCtx ctx; |
|||
private final TbCallback callback; |
|||
|
|||
@Override |
|||
public MsgType getMsgType() { |
|||
return MsgType.CF_ARGUMENT_RESET_MSG; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
/** |
|||
* 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.actors.calculatedField; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.audit.ActionType; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.MsgType; |
|||
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
import org.thingsboard.server.common.util.ProtoUtils; |
|||
import org.thingsboard.server.gen.transport.TransportProtos.EntityActionEventProto; |
|||
|
|||
@Data |
|||
@Builder |
|||
public class CalculatedFieldEntityActionEventMsg implements ToCalculatedFieldSystemMsg { |
|||
|
|||
private final TenantId tenantId; |
|||
private final EntityId entityId; |
|||
private final JsonNode entity; |
|||
private final ActionType action; |
|||
private final TbCallback callback; |
|||
|
|||
public static CalculatedFieldEntityActionEventMsg fromProto(EntityActionEventProto proto, |
|||
TbCallback callback) { |
|||
return CalculatedFieldEntityActionEventMsg.builder() |
|||
.tenantId((TenantId) ProtoUtils.fromProto(proto.getTenantId())) |
|||
.entityId(ProtoUtils.fromProto(proto.getEntityId())) |
|||
.entity(JacksonUtil.toJsonNode(proto.getEntity())) |
|||
.action(ActionType.valueOf(proto.getAction())) |
|||
.callback(callback) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public MsgType getMsgType() { |
|||
return MsgType.CF_ENTITY_ACTION_EVENT_MSG; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
/** |
|||
* 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.actors.calculatedField; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.MsgType; |
|||
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
|
|||
@Data |
|||
public class CalculatedFieldReevaluateMsg implements ToCalculatedFieldSystemMsg { |
|||
|
|||
private final TenantId tenantId; |
|||
private final CalculatedFieldCtx ctx; |
|||
|
|||
@Override |
|||
public MsgType getMsgType() { |
|||
return MsgType.CF_REEVALUATE_MSG; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.actors.calculatedField; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.audit.ActionType; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.msg.MsgType; |
|||
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
|||
import org.thingsboard.server.common.msg.queue.TbCallback; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
|
|||
@Data |
|||
public class CalculatedFieldRelationActionMsg implements ToCalculatedFieldSystemMsg { |
|||
|
|||
private final TenantId tenantId; |
|||
private final EntityId relatedEntityId; |
|||
private final ActionType action; |
|||
private final CalculatedFieldCtx calculatedField; |
|||
private final TbCallback callback; |
|||
|
|||
public CalculatedFieldRelationActionMsg(TenantId tenantId, |
|||
EntityId relatedEntityId, ActionType action, |
|||
CalculatedFieldCtx calculatedField, |
|||
TbCallback callback) { |
|||
this.tenantId = tenantId; |
|||
this.relatedEntityId = relatedEntityId; |
|||
this.action = action; |
|||
this.calculatedField = calculatedField; |
|||
this.callback = callback; |
|||
} |
|||
|
|||
@Override |
|||
public MsgType getMsgType() { |
|||
return MsgType.CF_RELATION_ACTION_MSG; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,82 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.rule.engine.action.TbAlarmResult; |
|||
import org.thingsboard.server.common.data.DataConstants; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Builder |
|||
@RequiredArgsConstructor |
|||
public class AlarmCalculatedFieldResult implements CalculatedFieldResult { |
|||
|
|||
private final TbAlarmResult alarmResult; |
|||
|
|||
@Override |
|||
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) { |
|||
TbMsgType msgType; |
|||
TbMsgMetaData metaData = new TbMsgMetaData(); |
|||
if (alarmResult.isCreated()) { |
|||
msgType = TbMsgType.ALARM_CREATED; |
|||
metaData.putValue(DataConstants.IS_NEW_ALARM, Boolean.TRUE.toString()); |
|||
} else if (alarmResult.isUpdated()) { |
|||
msgType = TbMsgType.ALARM_UPDATED; |
|||
metaData.putValue(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); |
|||
} else if (alarmResult.isSeverityUpdated()) { |
|||
msgType = TbMsgType.ALARM_SEVERITY_UPDATED; |
|||
metaData.putValue(DataConstants.IS_EXISTING_ALARM, Boolean.TRUE.toString()); |
|||
metaData.putValue(DataConstants.IS_SEVERITY_UPDATED_ALARM, Boolean.TRUE.toString()); |
|||
} else { |
|||
msgType = TbMsgType.ALARM_CLEAR; |
|||
metaData.putValue(DataConstants.IS_CLEARED_ALARM, Boolean.TRUE.toString()); |
|||
} |
|||
if (alarmResult.getConditionRepeats() != null) { |
|||
metaData.putValue(DataConstants.ALARM_CONDITION_REPEATS, String.valueOf(alarmResult.getConditionRepeats())); |
|||
} |
|||
if (alarmResult.getConditionDuration() != null) { |
|||
metaData.putValue(DataConstants.ALARM_CONDITION_DURATION, String.valueOf(alarmResult.getConditionDuration())); |
|||
} |
|||
|
|||
return TbMsg.newMsg() |
|||
.type(msgType) |
|||
.originator(entityId) |
|||
.data(JacksonUtil.toString(alarmResult.getAlarm())) |
|||
.metaData(metaData) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public String stringValue() { |
|||
return alarmResult != null ? JacksonUtil.toString(alarmResult) : null; |
|||
} |
|||
|
|||
@Override |
|||
public boolean isEmpty() { |
|||
return alarmResult == null; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.stereotype.Service; |
|||
import org.thingsboard.server.common.data.Customer; |
|||
import org.thingsboard.server.common.data.DeviceInfo; |
|||
import org.thingsboard.server.common.data.DeviceInfoFilter; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.asset.Asset; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageDataIterable; |
|||
import org.thingsboard.server.dao.asset.AssetService; |
|||
import org.thingsboard.server.dao.customer.CustomerService; |
|||
import org.thingsboard.server.dao.device.DeviceService; |
|||
|
|||
import java.util.HashSet; |
|||
import java.util.Set; |
|||
|
|||
@Service |
|||
@RequiredArgsConstructor |
|||
public class OwnerService { |
|||
|
|||
private final DeviceService deviceService; |
|||
private final AssetService assetService; |
|||
private final CustomerService customerService; |
|||
|
|||
public EntityId getOwner(TenantId tenantId, EntityId entityId) { |
|||
return switch (entityId.getEntityType()) { |
|||
case DEVICE -> deviceService.findDeviceById(tenantId, (DeviceId) entityId).getOwnerId(); |
|||
case ASSET -> assetService.findAssetById(tenantId, (AssetId) entityId).getOwnerId(); |
|||
case CUSTOMER -> tenantId; |
|||
default -> throw new UnsupportedOperationException(); |
|||
}; |
|||
} |
|||
|
|||
public Set<EntityId> getOwnedEntities(TenantId tenantId, EntityId ownerId) { |
|||
Set<EntityId> ownedEntities = new HashSet<>(); |
|||
if (EntityType.CUSTOMER.equals(ownerId.getEntityType())) { |
|||
PageDataIterable<DeviceInfo> deviceIdInfos = new PageDataIterable<>(pageLink -> deviceService.findDeviceInfosByFilter(DeviceInfoFilter.builder().tenantId(tenantId).customerId((CustomerId) ownerId).build(), pageLink), 1000); |
|||
deviceIdInfos.forEach(deviceInfo -> ownedEntities.add(deviceInfo.getId())); |
|||
|
|||
PageDataIterable<Asset> assets = new PageDataIterable<>(pageLink -> assetService.findAssetsByTenantIdAndCustomerId(tenantId, (CustomerId) ownerId, pageLink), 1000); |
|||
assets.forEach(asset -> ownedEntities.add(asset.getId())); |
|||
} else if (EntityType.TENANT.equals(ownerId.getEntityType())) { |
|||
PageDataIterable<DeviceInfo> deviceIdInfos = new PageDataIterable<>(pageLink -> deviceService.findDeviceInfosByFilter(DeviceInfoFilter.builder().tenantId((TenantId) ownerId).customerId(new CustomerId(CustomerId.NULL_UUID)).build(), pageLink), 1000); |
|||
deviceIdInfos.forEach(deviceInfo -> ownedEntities.add(deviceInfo.getId())); |
|||
|
|||
PageDataIterable<Asset> assets = new PageDataIterable<>(pageLink -> assetService.findAssetsByTenantIdAndCustomerId((TenantId) ownerId, new CustomerId(CustomerId.NULL_UUID), pageLink), 1000); |
|||
assets.forEach(asset -> ownedEntities.add(asset.getId())); |
|||
|
|||
PageDataIterable<Customer> customers = new PageDataIterable<>(pageLink -> customerService.findCustomersByTenantId((TenantId) ownerId, pageLink), 1000); |
|||
customers.forEach(customer -> ownedEntities.add(customer.getId())); |
|||
} |
|||
return ownedEntities; |
|||
} |
|||
|
|||
} |
|||
@ -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.service.cf; |
|||
|
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.util.CollectionsUtil; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
|
|||
import java.util.List; |
|||
|
|||
@Data |
|||
@Builder |
|||
public final class PropagationCalculatedFieldResult implements CalculatedFieldResult { |
|||
|
|||
private final List<EntityId> propagationEntityIds; |
|||
private final TelemetryCalculatedFieldResult result; |
|||
|
|||
@Override |
|||
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) { |
|||
return result.toTbMsg(entityId, cfIds); |
|||
} |
|||
|
|||
@Override |
|||
public String stringValue() { |
|||
return result.stringValue(); |
|||
} |
|||
|
|||
@Override |
|||
public boolean isEmpty() { |
|||
return CollectionsUtil.isEmpty(propagationEntityIds) || result.isEmpty(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,76 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import lombok.Builder; |
|||
import lombok.Data; |
|||
import org.thingsboard.server.common.data.AttributeScope; |
|||
import org.thingsboard.server.common.data.cf.configuration.OutputType; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.msg.TbMsg; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
|
|||
import java.util.List; |
|||
import java.util.Map; |
|||
|
|||
import static org.thingsboard.server.common.data.DataConstants.SCOPE; |
|||
|
|||
@Data |
|||
@Builder |
|||
public final class TelemetryCalculatedFieldResult implements CalculatedFieldResult { |
|||
|
|||
private final OutputType type; |
|||
private final AttributeScope scope; |
|||
private final JsonNode result; |
|||
|
|||
public static final TelemetryCalculatedFieldResult EMPTY = TelemetryCalculatedFieldResult.builder().result(null).build(); |
|||
|
|||
@Override |
|||
public TbMsg toTbMsg(EntityId entityId, List<CalculatedFieldId> cfIds) { |
|||
TbMsgType msgType = switch (type) { |
|||
case ATTRIBUTES -> TbMsgType.POST_ATTRIBUTES_REQUEST; |
|||
case TIME_SERIES -> TbMsgType.POST_TELEMETRY_REQUEST; |
|||
}; |
|||
TbMsgMetaData metaData = switch (type) { |
|||
case ATTRIBUTES -> new TbMsgMetaData(Map.of(SCOPE, scope.name())); |
|||
case TIME_SERIES -> TbMsgMetaData.EMPTY; |
|||
}; |
|||
return TbMsg.newMsg() |
|||
.type(msgType) |
|||
.originator(entityId) |
|||
.previousCalculatedFieldIds(cfIds) |
|||
.data(stringValue()) |
|||
.metaData(metaData) |
|||
.build(); |
|||
} |
|||
|
|||
@Override |
|||
public String stringValue() { |
|||
return result == null ? null : result.toString(); |
|||
} |
|||
|
|||
@Override |
|||
public boolean isEmpty() { |
|||
return result == null || result.isMissingNode() || result.isNull() || |
|||
(result.isObject() && result.isEmpty()) || |
|||
(result.isArray() && result.isEmpty()) || |
|||
(result.isTextual() && result.asText().isEmpty()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,241 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
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.AggFunctionInput; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggInput; |
|||
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.RelatedEntitiesAggregationCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
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 org.thingsboard.server.service.cf.ctx.state.aggregation.function.AggEntry; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Map.Entry; |
|||
import java.util.concurrent.ScheduledFuture; |
|||
|
|||
import static java.util.concurrent.TimeUnit.SECONDS; |
|||
|
|||
@Slf4j |
|||
@Getter |
|||
public class RelatedEntitiesAggregationCalculatedFieldState extends BaseCalculatedFieldState { |
|||
|
|||
@Setter |
|||
private long lastArgsRefreshTs = -1; |
|||
@Setter |
|||
private long lastMetricsEvalTs = -1; |
|||
@Setter |
|||
private long lastRelatedEntitiesRefreshTs = -1; |
|||
private long deduplicationIntervalMs = -1; |
|||
private Map<String, AggMetric> metrics; |
|||
|
|||
private ScheduledFuture<?> reevaluationFuture; |
|||
|
|||
public RelatedEntitiesAggregationCalculatedFieldState(EntityId entityId) { |
|||
super(entityId); |
|||
} |
|||
|
|||
@Override |
|||
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { |
|||
super.setCtx(ctx, actorCtx); |
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
|||
metrics = configuration.getMetrics(); |
|||
deduplicationIntervalMs = SECONDS.toMillis(configuration.getDeduplicationIntervalInSec()); |
|||
} |
|||
|
|||
@Override |
|||
public void close() { |
|||
super.close(); |
|||
if (reevaluationFuture != null) { |
|||
reevaluationFuture.cancel(true); |
|||
reevaluationFuture = null; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void reset() { // must reset everything dependent on arguments
|
|||
super.reset(); |
|||
lastArgsRefreshTs = -1; |
|||
lastMetricsEvalTs = -1; |
|||
lastRelatedEntitiesRefreshTs = -1; |
|||
metrics = null; |
|||
} |
|||
|
|||
public void updateLastRelatedEntitiesRefreshTs() { |
|||
lastRelatedEntitiesRefreshTs = System.currentTimeMillis(); |
|||
} |
|||
|
|||
@Override |
|||
public CalculatedFieldType getType() { |
|||
return CalculatedFieldType.RELATED_ENTITIES_AGGREGATION; |
|||
} |
|||
|
|||
@Override |
|||
public Map<String, ArgumentEntry> update(Map<String, ArgumentEntry> argumentValues, CalculatedFieldCtx ctx) { |
|||
lastArgsRefreshTs = System.currentTimeMillis(); |
|||
return super.update(argumentValues, ctx); |
|||
} |
|||
|
|||
public List<EntityId> checkRelatedEntities(List<EntityId> relatedEntities) { |
|||
Map<EntityId, Map<String, ArgumentEntry>> entityInputs = prepareInputs(); |
|||
findOutdatedEntities(entityInputs, relatedEntities).forEach(this::cleanupEntityData); |
|||
updateLastRelatedEntitiesRefreshTs(); |
|||
return findMissingEntities(entityInputs, relatedEntities); |
|||
} |
|||
|
|||
private List<EntityId> findMissingEntities(Map<EntityId, Map<String, ArgumentEntry>> entityInputs, List<EntityId> relatedEntities) { |
|||
List<EntityId> missing = new ArrayList<>(); |
|||
relatedEntities.forEach(entityId -> { |
|||
if (!entityInputs.containsKey(entityId)) { |
|||
missing.add(entityId); |
|||
log.warn("[{}] Missing related entity inputs for {}", ctx.getCfId(), entityId); |
|||
} |
|||
}); |
|||
return missing; |
|||
} |
|||
|
|||
private List<EntityId> findOutdatedEntities(Map<EntityId, Map<String, ArgumentEntry>> entityInputs, List<EntityId> relatedEntities) { |
|||
List<EntityId> outdated = new ArrayList<>(); |
|||
entityInputs.keySet().forEach(entityId -> { |
|||
if (!relatedEntities.contains(entityId)) { |
|||
outdated.add(entityId); |
|||
log.warn("[{}] CF state keeps outdated related entity {}", ctx.getCfId(), entityId); |
|||
} |
|||
}); |
|||
return outdated; |
|||
} |
|||
|
|||
public Map<String, ArgumentEntry> updateEntityData(Map<String, ArgumentEntry> fetchedArgs) { |
|||
lastMetricsEvalTs = -1; |
|||
return update(fetchedArgs, ctx); |
|||
} |
|||
|
|||
public void cleanupEntityData(EntityId relatedEntityId) { |
|||
arguments.values().forEach(argEntry -> { |
|||
RelatedEntitiesArgumentEntry aggEntry = (RelatedEntitiesArgumentEntry) argEntry; |
|||
aggEntry.getEntityInputs().remove(relatedEntityId); |
|||
}); |
|||
lastMetricsEvalTs = -1; |
|||
lastArgsRefreshTs = System.currentTimeMillis(); |
|||
} |
|||
|
|||
public void scheduleReevaluation() { |
|||
ScheduledFuture<?> future = ctx.scheduleReevaluation(deduplicationIntervalMs, actorCtx); |
|||
if (future != null) { |
|||
reevaluationFuture = future; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) throws Exception { |
|||
boolean cfUpdated = updatedArgs != null && updatedArgs.isEmpty(); |
|||
if (shouldRecalculate() || cfUpdated) { |
|||
Output output = ctx.getOutput(); |
|||
ObjectNode aggResult = aggregateMetrics(output); |
|||
lastMetricsEvalTs = System.currentTimeMillis(); |
|||
scheduleReevaluation(); |
|||
return Futures.immediateFuture(TelemetryCalculatedFieldResult.builder() |
|||
.type(output.getType()) |
|||
.scope(output.getScope()) |
|||
.result(toSimpleResult(ctx.isUseLatestTs(), aggResult)) |
|||
.build()); |
|||
} else { |
|||
return Futures.immediateFuture(TelemetryCalculatedFieldResult.EMPTY); |
|||
} |
|||
} |
|||
|
|||
private boolean shouldRecalculate() { |
|||
boolean intervalPassed = lastMetricsEvalTs <= System.currentTimeMillis() - deduplicationIntervalMs; |
|||
boolean argsUpdatedDuringInterval = lastArgsRefreshTs > lastMetricsEvalTs; |
|||
return intervalPassed && argsUpdatedDuringInterval; |
|||
} |
|||
|
|||
private Map<EntityId, Map<String, ArgumentEntry>> prepareInputs() { |
|||
Map<EntityId, Map<String, ArgumentEntry>> inputs = new HashMap<>(); |
|||
for (Map.Entry<String, ArgumentEntry> argEntry : arguments.entrySet()) { |
|||
String key = argEntry.getKey(); |
|||
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = (RelatedEntitiesArgumentEntry) argEntry.getValue(); |
|||
relatedEntitiesArgumentEntry.getEntityInputs().forEach((entityId, argumentEntry) -> { |
|||
inputs.computeIfAbsent(entityId, k -> new HashMap<>()).put(key, argumentEntry); |
|||
}); |
|||
} |
|||
return inputs; |
|||
} |
|||
|
|||
private ObjectNode aggregateMetrics(Output output) throws Exception { |
|||
ObjectNode aggResult = JacksonUtil.newObjectNode(); |
|||
Map<EntityId, Map<String, ArgumentEntry>> inputs = prepareInputs(); |
|||
for (Entry<String, AggMetric> entry : metrics.entrySet()) { |
|||
String metricKey = entry.getKey(); |
|||
AggMetric metric = entry.getValue(); |
|||
|
|||
AggEntry aggMetricEntry = AggEntry.createAggFunction(metric.getFunction()); |
|||
aggregateMetric(metric, aggMetricEntry, inputs); |
|||
aggMetricEntry.result(output.getDecimalsByDefault()).ifPresent(result -> { |
|||
aggResult.set(metricKey, JacksonUtil.valueToTree(result)); |
|||
}); |
|||
} |
|||
return aggResult; |
|||
} |
|||
|
|||
private void aggregateMetric(AggMetric metric, AggEntry aggEntry, Map<EntityId, Map<String, ArgumentEntry>> inputs) throws Exception { |
|||
for (Map<String, ArgumentEntry> entityInputs : inputs.values()) { |
|||
if (applyAggregation(metric.getFilter(), entityInputs)) { |
|||
Object arg = resolveAggregationInput(metric.getInput(), entityInputs); |
|||
if (arg != null) { |
|||
aggEntry.update(arg); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
private boolean applyAggregation(String filter, Map<String, ArgumentEntry> entityInputs) throws Exception { |
|||
if (filter == null || filter.isEmpty()) { |
|||
return true; |
|||
} else { |
|||
Object filterResult = ctx.evaluateTbelExpression(filter, entityInputs, getLatestTimestamp()).get(); |
|||
return filterResult instanceof Boolean booleanResult && booleanResult; |
|||
} |
|||
} |
|||
|
|||
private Object resolveAggregationInput(AggInput aggInput, Map<String, ArgumentEntry> entityInputs) throws Exception { |
|||
if (aggInput instanceof AggFunctionInput functionInput) { |
|||
return ctx.evaluateTbelExpression(functionInput.getFunction(), entityInputs, getLatestTimestamp()).get(); |
|||
} else { |
|||
String inputKey = ((AggKeyInput) aggInput).getKey(); |
|||
return entityInputs.get(inputKey).getValue(); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import org.thingsboard.script.api.tbel.TbelCfArg; |
|||
import org.thingsboard.script.api.tbel.TbelCfRelatedEntitiesArgumentValue; |
|||
import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
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; |
|||
import java.util.stream.Collectors; |
|||
|
|||
@Data |
|||
@AllArgsConstructor |
|||
public class RelatedEntitiesArgumentEntry implements ArgumentEntry { |
|||
|
|||
private final Map<EntityId, ArgumentEntry> entityInputs; |
|||
|
|||
private boolean forceResetPrevious; |
|||
|
|||
@Override |
|||
public ArgumentEntryType getType() { |
|||
return ArgumentEntryType.RELATED_ENTITIES; |
|||
} |
|||
|
|||
@Override |
|||
public Object getValue() { |
|||
return entityInputs; |
|||
} |
|||
|
|||
@Override |
|||
public boolean updateEntry(ArgumentEntry entry) { |
|||
if (entry instanceof RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry) { |
|||
entityInputs.putAll(relatedEntitiesArgumentEntry.entityInputs); |
|||
return true; |
|||
} else if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) { |
|||
if (entry.isForceResetPrevious()) { |
|||
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); |
|||
return true; |
|||
} |
|||
ArgumentEntry argumentEntry = entityInputs.get(singleValueArgumentEntry.getEntityId()); |
|||
if (argumentEntry != null) { |
|||
argumentEntry.updateEntry(singleValueArgumentEntry); |
|||
} else { |
|||
entityInputs.put(singleValueArgumentEntry.getEntityId(), singleValueArgumentEntry); |
|||
} |
|||
return true; |
|||
} else { |
|||
throw new IllegalArgumentException("Unsupported argument entry type for aggregation argument entry: " + entry.getType()); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public boolean isEmpty() { |
|||
return entityInputs.isEmpty(); |
|||
} |
|||
|
|||
@Override |
|||
public TbelCfArg toTbelCfArg() { |
|||
var inputs = entityInputs.entrySet().stream() |
|||
.collect(Collectors.toMap( |
|||
e -> e.getKey().getId(), |
|||
e -> (TbelCfSingleValueArg) e.getValue().toTbelCfArg() |
|||
)); |
|||
return new TbelCfRelatedEntitiesArgumentValue(inputs); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,58 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonIgnore; |
|||
import com.fasterxml.jackson.annotation.JsonSubTypes; |
|||
import com.fasterxml.jackson.annotation.JsonTypeInfo; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
@JsonTypeInfo( |
|||
use = JsonTypeInfo.Id.NAME, |
|||
include = JsonTypeInfo.As.PROPERTY, |
|||
property = "type" |
|||
) |
|||
@JsonSubTypes({ |
|||
@JsonSubTypes.Type(value = AvgAggEntry.class, name = "AVG"), |
|||
@JsonSubTypes.Type(value = CountAggEntry.class, name = "COUNT"), |
|||
@JsonSubTypes.Type(value = CountUniqueAggEntry.class, name = "COUNT_UNIQUE"), |
|||
@JsonSubTypes.Type(value = MaxAggEntry.class, name = "MAX"), |
|||
@JsonSubTypes.Type(value = MinAggEntry.class, name = "MIN"), |
|||
@JsonSubTypes.Type(value = SumAggEntry.class, name = "SUM") |
|||
}) |
|||
public interface AggEntry { |
|||
|
|||
@JsonIgnore |
|||
AggFunction getType(); |
|||
|
|||
void update(Object value); |
|||
|
|||
Optional<Object> result(Integer precision); |
|||
|
|||
static AggEntry createAggFunction(AggFunction function) { |
|||
return switch (function) { |
|||
case MIN -> new MinAggEntry(); |
|||
case MAX -> new MaxAggEntry(); |
|||
case SUM -> new SumAggEntry(); |
|||
case AVG -> new AvgAggEntry(); |
|||
case COUNT -> new CountAggEntry(); |
|||
case COUNT_UNIQUE -> new CountUniqueAggEntry(); |
|||
}; |
|||
} |
|||
|
|||
} |
|||
@ -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.service.cf.ctx.state.aggregation.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.math.BigDecimal; |
|||
import java.math.RoundingMode; |
|||
|
|||
public class AvgAggEntry extends BaseAggEntry { |
|||
|
|||
private BigDecimal sum = BigDecimal.ZERO; |
|||
private long count = 0L; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value != 0.0) { |
|||
sum = sum.add(BigDecimal.valueOf(value)); |
|||
} |
|||
this.count++; |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
double result = sum.divide(BigDecimal.valueOf(count), RoundingMode.HALF_UP).doubleValue(); |
|||
return TbUtils.roundResult(result, precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.AVG; |
|||
} |
|||
} |
|||
@ -0,0 +1,55 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
public abstract class BaseAggEntry implements AggEntry { |
|||
|
|||
private boolean hasResult = false; |
|||
|
|||
@Override |
|||
public void update(Object value) { |
|||
doUpdate(extractDoubleValue(value)); |
|||
hasResult = true; |
|||
} |
|||
|
|||
@Override |
|||
public Optional<Object> result(Integer precision) { |
|||
if (hasResult) { |
|||
hasResult = false; |
|||
return Optional.of(prepareResult(precision)); |
|||
} else { |
|||
return Optional.empty(); |
|||
} |
|||
} |
|||
|
|||
protected abstract void doUpdate(double value); |
|||
|
|||
protected abstract Object prepareResult(Integer precision); |
|||
|
|||
protected double extractDoubleValue(Object value) { |
|||
try { |
|||
if (value instanceof Number number) { |
|||
return number.doubleValue(); |
|||
} |
|||
return Double.parseDouble(value.toString()); |
|||
} catch (Exception e) { |
|||
throw new NumberFormatException("Cannot parse value " + value.toString()); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
public class CountAggEntry implements AggEntry { |
|||
|
|||
private long count = 0L; |
|||
|
|||
@Override |
|||
public void update(Object value) { |
|||
count++; |
|||
} |
|||
|
|||
@Override |
|||
public Optional<Object> result(Integer precision) { |
|||
return Optional.of(count); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.COUNT; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
public class CountUniqueAggEntry implements AggEntry { |
|||
|
|||
private Set<String> items; |
|||
|
|||
@Override |
|||
public void update(Object value) { |
|||
if (value != null) { |
|||
items.add(String.valueOf(value)); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public Optional<Object> result(Integer precision) { |
|||
return Optional.of(items.size()); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.COUNT_UNIQUE; |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
public class MaxAggEntry extends BaseAggEntry { |
|||
|
|||
private double max = Double.MIN_VALUE; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value > max) { |
|||
max = value; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
return TbUtils.roundResult(max, precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.MAX; |
|||
} |
|||
} |
|||
@ -0,0 +1,41 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
public class MinAggEntry extends BaseAggEntry { |
|||
|
|||
private double min = Double.MAX_VALUE; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value < min) { |
|||
min = value; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
return TbUtils.roundResult(min, precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.MIN; |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
/** |
|||
* 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.function; |
|||
|
|||
import org.thingsboard.script.api.tbel.TbUtils; |
|||
import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; |
|||
|
|||
import java.math.BigDecimal; |
|||
|
|||
public class SumAggEntry extends BaseAggEntry { |
|||
|
|||
private BigDecimal sum = BigDecimal.ZERO; |
|||
|
|||
@Override |
|||
protected void doUpdate(double value) { |
|||
if (value != 0.0) { |
|||
sum = sum.add(BigDecimal.valueOf(value)); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
protected Object prepareResult(Integer precision) { |
|||
return TbUtils.roundResult(sum.doubleValue(), precision); |
|||
} |
|||
|
|||
@Override |
|||
public AggFunction getType() { |
|||
return AggFunction.SUM; |
|||
} |
|||
} |
|||
@ -0,0 +1,552 @@ |
|||
/** |
|||
* 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.alarm; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.EqualsAndHashCode; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import lombok.SneakyThrows; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.common.util.KvUtil; |
|||
import org.thingsboard.rule.engine.action.TbAlarmResult; |
|||
import org.thingsboard.server.actors.TbActorRef; |
|||
import org.thingsboard.server.common.data.StringUtils; |
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.alarm.AlarmApiCallResult; |
|||
import org.thingsboard.server.common.data.alarm.AlarmCreateOrUpdateActiveRequest; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.alarm.AlarmUpdateRequest; |
|||
import org.thingsboard.server.common.data.alarm.rule.AlarmRule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionType; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; |
|||
import org.thingsboard.server.common.data.audit.ActionType; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.id.DashboardId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.kv.KvEntry; |
|||
import org.thingsboard.server.service.cf.AlarmCalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.CalculatedFieldResult; |
|||
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 org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; |
|||
|
|||
import java.util.Comparator; |
|||
import java.util.Map; |
|||
import java.util.TreeMap; |
|||
import java.util.concurrent.ScheduledFuture; |
|||
import java.util.concurrent.atomic.AtomicBoolean; |
|||
import java.util.function.Function; |
|||
|
|||
import static org.thingsboard.server.common.data.StringUtils.equalsAny; |
|||
import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; |
|||
import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.FALSE; |
|||
import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.NOT_YET_TRUE; |
|||
import static org.thingsboard.server.service.cf.ctx.state.alarm.AlarmEvalResult.Status.TRUE; |
|||
|
|||
@EqualsAndHashCode(callSuper = true) |
|||
@Slf4j |
|||
public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { |
|||
|
|||
private AlarmCalculatedFieldConfiguration configuration; |
|||
private String alarmType; |
|||
|
|||
@Getter |
|||
private final Map<AlarmSeverity, AlarmRuleState> createRuleStates = new TreeMap<>(Comparator.comparing(Enum::ordinal)); |
|||
@Getter |
|||
@Setter |
|||
private AlarmRuleState clearRuleState; |
|||
|
|||
@Getter |
|||
private Alarm currentAlarm; |
|||
private boolean initialFetchDone; |
|||
|
|||
// TODO: deprecate device profile node, describe the differences and improvements
|
|||
|
|||
public AlarmCalculatedFieldState(EntityId entityId) { |
|||
super(entityId); |
|||
} |
|||
|
|||
@Override |
|||
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { |
|||
super.setCtx(ctx, actorCtx); |
|||
this.configuration = getConfiguration(ctx); |
|||
this.alarmType = ctx.getCalculatedField().getName(); |
|||
|
|||
Map<AlarmSeverity, AlarmRule> createRules = configuration.getCreateRules(); |
|||
createRules.forEach((severity, rule) -> { |
|||
AlarmRuleState ruleState = createRuleStates.get(severity); |
|||
if (ruleState != null) { |
|||
ruleState.setAlarmRule(rule); |
|||
} |
|||
}); |
|||
AlarmRule clearRule = configuration.getClearRule(); |
|||
if (clearRule != null && clearRuleState != null) { |
|||
clearRuleState.setAlarmRule(clearRule); |
|||
} |
|||
|
|||
if (currentAlarm != null && !currentAlarm.getType().equals(alarmType)) { |
|||
currentAlarm = null; |
|||
initialFetchDone = false; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public void init() { |
|||
super.init(); |
|||
AtomicBoolean reevalNeeded = new AtomicBoolean(false); |
|||
Map<AlarmSeverity, AlarmRule> createRules = configuration.getCreateRules(); |
|||
for (AlarmSeverity severity : AlarmSeverity.values()) { |
|||
AlarmRule rule = createRules.get(severity); |
|||
if (rule != null) { |
|||
createRuleStates.compute(severity, (__, ruleState) -> { |
|||
return initRuleState(severity, rule, ruleState, reevalNeeded); |
|||
}); |
|||
} else { |
|||
AlarmRuleState state = createRuleStates.remove(severity); |
|||
if (state != null) { |
|||
clearState(state); |
|||
} |
|||
} |
|||
} |
|||
|
|||
AlarmRule clearRule = configuration.getClearRule(); |
|||
if (clearRule != null) { |
|||
clearRuleState = initRuleState(null, clearRule, clearRuleState, reevalNeeded); |
|||
} else { |
|||
if (clearRuleState != null) { |
|||
clearState(clearRuleState); |
|||
clearRuleState = null; |
|||
} |
|||
} |
|||
log.debug("Initialized create rule states {} and clear rule state {} for {}", createRuleStates, clearRuleState, configuration); |
|||
|
|||
if (reevalNeeded.get()) { |
|||
initCurrentAlarm(ctx); |
|||
createOrClearAlarms(state -> { |
|||
if (state.getCondition().getType() == AlarmConditionType.DURATION) { |
|||
AlarmEvalResult evalResult = state.reeval(System.currentTimeMillis(), ctx); |
|||
if (evalResult.getStatus() == TRUE || evalResult.getStatus() == NOT_YET_TRUE) { |
|||
ScheduledFuture<?> future = ctx.scheduleReevaluation(evalResult.getLeftDuration(), actorCtx); |
|||
if (future != null) { |
|||
state.setDurationCheckFuture(future); |
|||
} |
|||
} |
|||
} |
|||
return AlarmEvalResult.NOT_YET_TRUE; |
|||
}, ctx); |
|||
} |
|||
} |
|||
|
|||
private AlarmRuleState initRuleState(AlarmSeverity severity, AlarmRule rule, AlarmRuleState ruleState, AtomicBoolean reevalNeeded) { |
|||
if (ruleState == null) { |
|||
ruleState = new AlarmRuleState(severity, rule, this); |
|||
} else { |
|||
// when restored
|
|||
ruleState.setAlarmRule(rule); |
|||
ruleState.setActive(null); |
|||
AlarmCondition condition = rule.getCondition(); |
|||
if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) { |
|||
reevalNeeded.set(true); |
|||
} |
|||
} |
|||
return ruleState; |
|||
} |
|||
|
|||
@Override |
|||
public void reset() { |
|||
super.reset(); |
|||
configuration = null; |
|||
} |
|||
|
|||
@Override |
|||
public void close() { |
|||
super.close(); |
|||
for (AlarmRuleState state : createRuleStates.values()) { |
|||
clearState(state); |
|||
} |
|||
clearState(clearRuleState); |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) { |
|||
initCurrentAlarm(ctx); |
|||
TbAlarmResult result = createOrClearAlarms(state -> { |
|||
if (updatedArgs != null) { |
|||
boolean newEvent = !updatedArgs.isEmpty(); |
|||
AlarmEvalResult evalResult = state.eval(newEvent, ctx); |
|||
if (evalResult.getStatus() == NOT_YET_TRUE && evalResult.getLeftDuration() > 0) { |
|||
long leftDuration = evalResult.getLeftDuration(); |
|||
ScheduledFuture<?> future = ctx.scheduleReevaluation(leftDuration, actorCtx); |
|||
if (future != null) { |
|||
state.setDurationCheckFuture(future); |
|||
} |
|||
} |
|||
return evalResult; |
|||
} else { |
|||
return state.reeval(System.currentTimeMillis(), ctx); |
|||
} |
|||
}, ctx); |
|||
return Futures.immediateFuture(AlarmCalculatedFieldResult.builder() |
|||
.alarmResult(result) |
|||
.build()); |
|||
} |
|||
|
|||
public void processAlarmAction(Alarm alarm, ActionType action) { |
|||
switch (action) { |
|||
case ALARM_ACK -> processAlarmAck(alarm); |
|||
case ALARM_CLEAR -> processAlarmClear(alarm); |
|||
case ALARM_DELETE -> processAlarmDelete(alarm); |
|||
} |
|||
} |
|||
|
|||
private void processAlarmClear(Alarm alarm) { |
|||
currentAlarm = null; |
|||
createRuleStates.values().forEach(this::clearState); |
|||
clearState(clearRuleState); |
|||
} |
|||
|
|||
private void processAlarmAck(Alarm alarm) { |
|||
currentAlarm.setAcknowledged(alarm.isAcknowledged()); |
|||
currentAlarm.setAckTs(alarm.getAckTs()); |
|||
} |
|||
|
|||
private void processAlarmDelete(Alarm alarm) { |
|||
processAlarmClear(alarm); |
|||
} |
|||
|
|||
private TbAlarmResult createOrClearAlarms(Function<AlarmRuleState, AlarmEvalResult> evalFunction, |
|||
CalculatedFieldCtx ctx) { |
|||
TbAlarmResult result = null; |
|||
AlarmRuleState resultState = null; |
|||
AlarmRuleState.StateInfo resultStateInfo = null; |
|||
|
|||
for (AlarmRuleState state : createRuleStates.values()) { |
|||
AlarmEvalResult evalResult = evalFunction.apply(state); |
|||
log.debug("Evaluated create rule {} with args {}. Result: {}", state, arguments, evalResult); |
|||
if (evalResult.getStatus() == TRUE) { |
|||
resultState = state; |
|||
break; |
|||
} else if (evalResult.getStatus() == FALSE) { |
|||
clearState(state); |
|||
} |
|||
} |
|||
|
|||
if (resultState != null) { |
|||
result = calculateAlarmResult(resultState, ctx); |
|||
resultStateInfo = resultState.getStateInfo(); |
|||
log.debug("Alarm result for state {}: {}", resultState, result); |
|||
clearState(clearRuleState); |
|||
} else if (currentAlarm != null && clearRuleState != null) { |
|||
AlarmEvalResult evalResult = evalFunction.apply(clearRuleState); |
|||
log.debug("Evaluated clear rule {} with args {}. Result: {}", clearRuleState, arguments, evalResult); |
|||
if (evalResult.getStatus() == TRUE) { |
|||
resultStateInfo = clearRuleState.getStateInfo(); |
|||
clearState(clearRuleState); |
|||
for (AlarmRuleState state : createRuleStates.values()) { |
|||
clearState(state); |
|||
} |
|||
AlarmApiCallResult clearResult = ctx.getAlarmService().clearAlarm( |
|||
ctx.getTenantId(), currentAlarm.getId(), System.currentTimeMillis(), createDetails(clearRuleState), false |
|||
); |
|||
if (clearResult.isCleared()) { |
|||
result = TbAlarmResult.builder() |
|||
.isCleared(true) |
|||
.alarm(clearResult.getAlarm()) |
|||
.build(); |
|||
resultState = clearRuleState; |
|||
} |
|||
currentAlarm = null; |
|||
} else if (evalResult.getStatus() == FALSE) { |
|||
clearState(clearRuleState); |
|||
} |
|||
} |
|||
if (result != null && resultState != null) { |
|||
result.setConditionRepeats(resultStateInfo.eventCount()); |
|||
result.setConditionDuration(resultStateInfo.duration()); |
|||
} |
|||
return result; |
|||
} |
|||
|
|||
private void clearState(AlarmRuleState state) { |
|||
if (state != null) { |
|||
log.debug("Clearing rule state {}", state); |
|||
state.clear(); |
|||
} |
|||
} |
|||
|
|||
private void initCurrentAlarm(CalculatedFieldCtx ctx) { |
|||
if (!initialFetchDone) { |
|||
Alarm alarm = ctx.getAlarmService().findLatestActiveByOriginatorAndType(ctx.getTenantId(), entityId, alarmType); |
|||
if (alarm != null && !alarm.getStatus().isCleared()) { |
|||
currentAlarm = alarm; |
|||
} |
|||
initialFetchDone = true; |
|||
} |
|||
} |
|||
|
|||
private TbAlarmResult calculateAlarmResult(AlarmRuleState ruleState, CalculatedFieldCtx ctx) { |
|||
AlarmSeverity severity = ruleState.getSeverity(); |
|||
if (currentAlarm != null) { |
|||
currentAlarm.setEndTs(System.currentTimeMillis()); |
|||
AlarmSeverity oldSeverity = currentAlarm.getSeverity(); |
|||
// Skip update if severity is decreased.
|
|||
if (severity.ordinal() <= oldSeverity.ordinal()) { |
|||
currentAlarm.setDetails(createDetails(ruleState)); |
|||
currentAlarm.setSeverity(severity); |
|||
AlarmApiCallResult result = ctx.getAlarmService().updateAlarm(AlarmUpdateRequest.fromAlarm(currentAlarm)); |
|||
currentAlarm = result.getAlarm(); |
|||
return TbAlarmResult.fromAlarmResult(result); |
|||
} else { |
|||
return null; |
|||
} |
|||
} else { |
|||
var newAlarm = new Alarm(); |
|||
newAlarm.setType(alarmType); |
|||
newAlarm.setAcknowledged(false); |
|||
newAlarm.setCleared(false); |
|||
newAlarm.setSeverity(severity); |
|||
long startTs = latestTimestamp; |
|||
long currentTime = System.currentTimeMillis(); |
|||
if (startTs == 0L || startTs > currentTime) { |
|||
startTs = currentTime; |
|||
} |
|||
newAlarm.setStartTs(startTs); |
|||
newAlarm.setEndTs(startTs); |
|||
newAlarm.setDetails(createDetails(ruleState)); |
|||
newAlarm.setOriginator(entityId); |
|||
newAlarm.setTenantId(ctx.getTenantId()); |
|||
newAlarm.setPropagate(configuration.isPropagate()); |
|||
newAlarm.setPropagateToOwner(configuration.isPropagateToOwner()); |
|||
newAlarm.setPropagateToTenant(configuration.isPropagateToTenant()); |
|||
if (configuration.getPropagateRelationTypes() != null) { |
|||
newAlarm.setPropagateRelationTypes(configuration.getPropagateRelationTypes()); |
|||
} |
|||
AlarmApiCallResult result = ctx.getAlarmService().createAlarm(AlarmCreateOrUpdateActiveRequest.fromAlarm(newAlarm)); |
|||
currentAlarm = result.getAlarm(); |
|||
return TbAlarmResult.fromAlarmResult(result); |
|||
} |
|||
} |
|||
|
|||
private JsonNode createDetails(AlarmRuleState ruleState) { |
|||
JsonNode alarmDetails; |
|||
String alarmDetailsStr = ruleState.getAlarmRule().getAlarmDetails(); |
|||
DashboardId dashboardId = ruleState.getAlarmRule().getDashboardId(); |
|||
|
|||
if (StringUtils.isNotEmpty(alarmDetailsStr) || dashboardId != null) { |
|||
ObjectNode newDetails = JacksonUtil.newObjectNode(); |
|||
if (StringUtils.isNotEmpty(alarmDetailsStr)) { |
|||
for (Map.Entry<String, ArgumentEntry> entry : arguments.entrySet()) { |
|||
String key = entry.getKey(); |
|||
ArgumentEntry value = entry.getValue(); |
|||
alarmDetailsStr = alarmDetailsStr.replaceAll(String.format("\\$\\{%s}", key), String.valueOf(value.getValue())); |
|||
} |
|||
newDetails.put("data", alarmDetailsStr); |
|||
} |
|||
if (dashboardId != null) { |
|||
newDetails.put("dashboardId", dashboardId.getId().toString()); |
|||
} |
|||
alarmDetails = newDetails; |
|||
} else if (currentAlarm != null) { |
|||
alarmDetails = currentAlarm.getDetails(); |
|||
} else { |
|||
alarmDetails = JacksonUtil.newObjectNode(); |
|||
} |
|||
|
|||
return alarmDetails; |
|||
} |
|||
|
|||
@SneakyThrows |
|||
public boolean eval(AlarmConditionExpression expression, CalculatedFieldCtx ctx) { |
|||
if (expression instanceof TbelAlarmConditionExpression tbelExpression) { |
|||
Object result = ctx.evaluateTbelExpression(tbelExpression.getExpression(), this).get(); |
|||
if (result instanceof Boolean booleanResult) { |
|||
return booleanResult; |
|||
} else { |
|||
throw new IllegalStateException("Condition expression returned non-boolean value: '" + result + "'"); |
|||
} |
|||
} else { |
|||
SimpleAlarmConditionExpression simpleExpression = (SimpleAlarmConditionExpression) expression; |
|||
ComplexOperation operation = simpleExpression.getOperation(); |
|||
if (operation == null) { |
|||
operation = ComplexOperation.AND; |
|||
} |
|||
return switch (operation) { |
|||
case AND -> simpleExpression.getFilters().stream() |
|||
.allMatch(filter -> eval(getArgument(filter.getArgument()), filter)); |
|||
case OR -> simpleExpression.getFilters().stream() |
|||
.anyMatch(filter -> eval(getArgument(filter.getArgument()), filter)); |
|||
}; |
|||
} |
|||
} |
|||
|
|||
private boolean eval(SingleValueArgumentEntry argument, AlarmConditionFilter filter) { |
|||
ComplexOperation operation = filter.getOperation(); |
|||
if (operation == null) { |
|||
operation = ComplexOperation.AND; |
|||
} |
|||
return switch (operation) { |
|||
case AND -> filter.getPredicates().stream() |
|||
.allMatch(predicate -> eval(argument, predicate)); |
|||
case OR -> filter.getPredicates().stream() |
|||
.anyMatch(predicate -> eval(argument, predicate)); |
|||
}; |
|||
} |
|||
|
|||
private boolean eval(SingleValueArgumentEntry argument, KeyFilterPredicate predicate) { |
|||
return switch (predicate.getType()) { |
|||
case STRING -> evalStrPredicate(argument, (StringFilterPredicate) predicate); |
|||
case NUMERIC -> evalNumPredicate(argument, (NumericFilterPredicate) predicate); |
|||
case BOOLEAN -> evalBooleanPredicate(argument, (BooleanFilterPredicate) predicate); |
|||
case COMPLEX -> evalComplexPredicate(argument, (ComplexFilterPredicate) predicate); |
|||
}; |
|||
} |
|||
|
|||
private boolean evalComplexPredicate(SingleValueArgumentEntry argument, ComplexFilterPredicate complexPredicate) { |
|||
return switch (complexPredicate.getOperation()) { |
|||
case OR -> { |
|||
for (KeyFilterPredicate predicate : complexPredicate.getPredicates()) { |
|||
if (eval(argument, predicate)) { |
|||
yield true; |
|||
} |
|||
} |
|||
yield false; |
|||
} |
|||
case AND -> { |
|||
for (KeyFilterPredicate predicate : complexPredicate.getPredicates()) { |
|||
if (!eval(argument, predicate)) { |
|||
yield false; |
|||
} |
|||
} |
|||
yield true; |
|||
} |
|||
}; |
|||
} |
|||
|
|||
private boolean evalBooleanPredicate(SingleValueArgumentEntry argument, BooleanFilterPredicate predicate) { |
|||
Boolean value = KvUtil.getBoolValue(argument.getKvEntryValue()); |
|||
if (value == null) { |
|||
return false; |
|||
} |
|||
Boolean predicateValue = resolveValue(predicate.getValue(), KvUtil::getBoolValue); |
|||
if (predicateValue == null) { |
|||
return false; |
|||
} |
|||
return switch (predicate.getOperation()) { |
|||
case EQUAL -> value.equals(predicateValue); |
|||
case NOT_EQUAL -> !value.equals(predicateValue); |
|||
}; |
|||
} |
|||
|
|||
private boolean evalNumPredicate(SingleValueArgumentEntry argument, NumericFilterPredicate predicate) { |
|||
Double value = KvUtil.getDoubleValue(argument.getKvEntryValue()); |
|||
if (value == null) { |
|||
return false; |
|||
} |
|||
Double predicateValue = resolveValue(predicate.getValue(), KvUtil::getDoubleValue); |
|||
if (predicateValue == null) { |
|||
return false; |
|||
} |
|||
return switch (predicate.getOperation()) { |
|||
case NOT_EQUAL -> !value.equals(predicateValue); |
|||
case EQUAL -> value.equals(predicateValue); |
|||
case GREATER -> value > predicateValue; |
|||
case GREATER_OR_EQUAL -> value >= predicateValue; |
|||
case LESS -> value < predicateValue; |
|||
case LESS_OR_EQUAL -> value <= predicateValue; |
|||
}; |
|||
} |
|||
|
|||
private boolean evalStrPredicate(SingleValueArgumentEntry argument, StringFilterPredicate predicate) { |
|||
String value = KvUtil.getStringValue(argument.getKvEntryValue()); |
|||
if (value == null) { |
|||
return false; |
|||
} |
|||
String predicateValue = resolveValue(predicate.getValue(), KvUtil::getStringValue); |
|||
if (predicateValue == null) { |
|||
return false; |
|||
} |
|||
if (predicate.isIgnoreCase()) { |
|||
value = value.toLowerCase(); |
|||
predicateValue = predicateValue.toLowerCase(); |
|||
} |
|||
return switch (predicate.getOperation()) { |
|||
case CONTAINS -> value.contains(predicateValue); |
|||
case EQUAL -> value.equals(predicateValue); |
|||
case STARTS_WITH -> value.startsWith(predicateValue); |
|||
case ENDS_WITH -> value.endsWith(predicateValue); |
|||
case NOT_EQUAL -> !value.equals(predicateValue); |
|||
case NOT_CONTAINS -> !value.contains(predicateValue); |
|||
case IN -> equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); |
|||
case NOT_IN -> !equalsAny(value, splitByCommaWithoutQuotes(predicateValue)); |
|||
}; |
|||
} |
|||
|
|||
protected <T> T resolveValue(AlarmConditionValue<T> conditionValue, Function<KvEntry, T> mapper) { |
|||
T value = conditionValue.getStaticValue(); |
|||
if (value == null) { |
|||
String argument = conditionValue.getDynamicValueArgument(); |
|||
SingleValueArgumentEntry entry = getArgument(argument); |
|||
value = mapper.apply(entry.getKvEntryValue()); |
|||
if (value == null) { |
|||
throw new IllegalArgumentException("No proper value found for argument " + argument); |
|||
} |
|||
} |
|||
return value; |
|||
} |
|||
|
|||
protected SingleValueArgumentEntry getArgument(String key) { |
|||
SingleValueArgumentEntry entry = (SingleValueArgumentEntry) arguments.get(key); |
|||
if (entry == null) { |
|||
throw new IllegalArgumentException("Argument '" + key + "' is missing"); |
|||
} |
|||
return entry; |
|||
} |
|||
|
|||
private AlarmCalculatedFieldConfiguration getConfiguration(CalculatedFieldCtx ctx) { |
|||
return (AlarmCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
|||
} |
|||
|
|||
@Override |
|||
protected void validateNewEntry(String key, ArgumentEntry newEntry) { |
|||
if (!(newEntry instanceof SingleValueArgumentEntry)) { |
|||
throw new IllegalArgumentException("Only single value arguments supported"); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public CalculatedFieldType getType() { |
|||
return CalculatedFieldType.ALARM; |
|||
} |
|||
|
|||
} |
|||
@ -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.alarm; |
|||
|
|||
import lombok.Data; |
|||
import lombok.RequiredArgsConstructor; |
|||
|
|||
@Data |
|||
@RequiredArgsConstructor |
|||
public class AlarmEvalResult { |
|||
|
|||
public static final AlarmEvalResult TRUE = new AlarmEvalResult(Status.TRUE); |
|||
public static final AlarmEvalResult FALSE = new AlarmEvalResult(Status.FALSE); |
|||
public static final AlarmEvalResult NOT_YET_TRUE = new AlarmEvalResult(Status.NOT_YET_TRUE); |
|||
|
|||
private final Status status; |
|||
private final long leftDuration; |
|||
private final long leftEvents; |
|||
|
|||
public AlarmEvalResult(Status status) { |
|||
this(status, 0, 0); |
|||
} |
|||
|
|||
public static AlarmEvalResult notYetTrue(long leftEvents, long leftDuration) { |
|||
return new AlarmEvalResult(Status.NOT_YET_TRUE, leftDuration, leftEvents); |
|||
} |
|||
|
|||
public enum Status { |
|||
FALSE, NOT_YET_TRUE, TRUE; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,344 @@ |
|||
/** |
|||
* 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.alarm; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import lombok.Data; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.common.util.KvUtil; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.alarm.rule.AlarmRule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionType; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.DurationAlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmScheduleType; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.CustomTimeSchedule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.CustomTimeScheduleItem; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; |
|||
import org.thingsboard.server.common.msg.tools.SchedulerUtils; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
|
|||
import java.time.Instant; |
|||
import java.time.ZoneId; |
|||
import java.time.ZonedDateTime; |
|||
import java.util.Optional; |
|||
import java.util.concurrent.ScheduledFuture; |
|||
|
|||
@Data |
|||
@Slf4j |
|||
public class AlarmRuleState { |
|||
|
|||
private final AlarmSeverity severity; |
|||
private AlarmRule alarmRule; |
|||
private AlarmCalculatedFieldState state; |
|||
|
|||
private AlarmCondition condition; |
|||
|
|||
private long eventCount; |
|||
private long firstEventTs; // when duration condition started
|
|||
private long lastEventTs; |
|||
private transient long duration; |
|||
private ScheduledFuture<?> durationCheckFuture; |
|||
private Boolean active; |
|||
|
|||
public AlarmRuleState(AlarmSeverity severity, AlarmRule alarmRule, AlarmCalculatedFieldState state) { |
|||
this.severity = severity; |
|||
if (alarmRule != null) { |
|||
setAlarmRule(alarmRule); |
|||
} |
|||
this.state = state; |
|||
} |
|||
|
|||
public AlarmEvalResult eval(boolean newEvent, CalculatedFieldCtx ctx) { // on event or config change
|
|||
long ts = newEvent ? state.getLatestTimestamp() : System.currentTimeMillis(); |
|||
active = isActive(ts); |
|||
if (!active) { |
|||
return AlarmEvalResult.FALSE; |
|||
} |
|||
return doEval(newEvent, ctx); |
|||
} |
|||
|
|||
public AlarmEvalResult reeval(long ts, CalculatedFieldCtx ctx) { // on scheduled duration check or periodic re-eval for rules with schedule
|
|||
boolean active = isActive(ts); |
|||
switch (condition.getType()) { |
|||
case SIMPLE, REPEATING -> { |
|||
if (this.active == null || active != this.active) { |
|||
this.active = active; |
|||
if (active) { |
|||
return doEval(false, ctx); |
|||
} |
|||
} |
|||
if (active) { |
|||
return AlarmEvalResult.NOT_YET_TRUE; |
|||
} else { |
|||
return AlarmEvalResult.FALSE; |
|||
} |
|||
} |
|||
case DURATION -> { |
|||
if (!active) { |
|||
return AlarmEvalResult.FALSE; |
|||
} |
|||
long requiredDuration = getRequiredDurationInMs(); |
|||
if (requiredDuration > 0 && lastEventTs > 0 && ts > lastEventTs) { |
|||
duration = ts - firstEventTs; |
|||
long leftDuration = requiredDuration - duration; |
|||
if (leftDuration <= 0) { |
|||
return AlarmEvalResult.TRUE; |
|||
} else { |
|||
return AlarmEvalResult.notYetTrue(0, leftDuration); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return AlarmEvalResult.FALSE; |
|||
} |
|||
|
|||
public AlarmEvalResult doEval(boolean newEvent, CalculatedFieldCtx ctx) { |
|||
return switch (condition.getType()) { |
|||
case SIMPLE -> evalSimple(ctx); |
|||
case DURATION -> evalDuration(ctx); |
|||
case REPEATING -> evalRepeating(newEvent, ctx); |
|||
}; |
|||
} |
|||
|
|||
private AlarmEvalResult evalSimple(CalculatedFieldCtx ctx) { |
|||
return eval(condition.getExpression(), ctx) ? AlarmEvalResult.TRUE : AlarmEvalResult.FALSE; |
|||
} |
|||
|
|||
private AlarmEvalResult evalRepeating(boolean newEvent, CalculatedFieldCtx ctx) { |
|||
if (eval(condition.getExpression(), ctx)) { |
|||
if (newEvent) { |
|||
eventCount++; |
|||
} |
|||
long requiredRepeats = getIntValue(((RepeatingAlarmCondition) condition).getCount()); |
|||
if (requiredRepeats > 0) { |
|||
long leftRepeats = requiredRepeats - eventCount; |
|||
return leftRepeats <= 0 ? AlarmEvalResult.TRUE : AlarmEvalResult.notYetTrue(leftRepeats, 0); |
|||
} else { |
|||
return AlarmEvalResult.NOT_YET_TRUE; |
|||
} |
|||
} else { |
|||
return AlarmEvalResult.FALSE; |
|||
} |
|||
} |
|||
|
|||
private AlarmEvalResult evalDuration(CalculatedFieldCtx ctx) { |
|||
if (eval(condition.getExpression(), ctx)) { |
|||
long eventTs = state.getLatestTimestamp(); |
|||
if (lastEventTs > 0) { |
|||
if (eventTs > lastEventTs) { |
|||
if (firstEventTs == 0) { |
|||
firstEventTs = lastEventTs; |
|||
} |
|||
lastEventTs = eventTs; |
|||
} |
|||
} else { |
|||
firstEventTs = eventTs; |
|||
lastEventTs = eventTs; |
|||
} |
|||
duration = lastEventTs - firstEventTs; |
|||
long requiredDuration = getRequiredDurationInMs(); |
|||
if (requiredDuration > 0) { |
|||
long leftDuration = requiredDuration - duration; |
|||
if (leftDuration <= 0) { |
|||
return AlarmEvalResult.TRUE; |
|||
} else { |
|||
return AlarmEvalResult.notYetTrue(0, leftDuration); |
|||
} |
|||
} else { |
|||
return AlarmEvalResult.NOT_YET_TRUE; |
|||
} |
|||
} else { |
|||
return AlarmEvalResult.FALSE; |
|||
} |
|||
} |
|||
|
|||
private boolean isActive(long eventTs) { |
|||
if (condition.getSchedule() == null) { |
|||
return true; |
|||
} |
|||
AlarmSchedule schedule = state.resolveValue(condition.getSchedule(), entry -> Optional.ofNullable(KvUtil.getStringValue(entry)) |
|||
.map(this::parseSchedule).orElse(null)); |
|||
boolean active = switch (schedule.getType()) { |
|||
case ANY_TIME -> true; |
|||
case SPECIFIC_TIME -> isActiveSpecific((SpecificTimeSchedule) schedule, eventTs); |
|||
case CUSTOM -> isActiveCustom((CustomTimeSchedule) schedule, eventTs); |
|||
}; |
|||
log.trace("Alarm rule active = {} for schedule {}", active, schedule); |
|||
return active; |
|||
} |
|||
|
|||
private boolean isActiveSpecific(SpecificTimeSchedule schedule, long eventTs) { |
|||
ZoneId zoneId = SchedulerUtils.getZoneId(schedule.getTimezone()); |
|||
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(eventTs), zoneId); |
|||
if (schedule.getDaysOfWeek().size() != 7) { |
|||
int dayOfWeek = zdt.getDayOfWeek().getValue(); |
|||
if (!schedule.getDaysOfWeek().contains(dayOfWeek)) { |
|||
return false; |
|||
} |
|||
} |
|||
long endsOn = schedule.getEndsOn(); |
|||
if (endsOn == 0) { |
|||
// 24 hours in milliseconds
|
|||
endsOn = 86400000; |
|||
} |
|||
|
|||
return isActive(eventTs, zoneId, zdt, schedule.getStartsOn(), endsOn); |
|||
} |
|||
|
|||
private boolean isActiveCustom(CustomTimeSchedule schedule, long eventTs) { |
|||
ZoneId zoneId = SchedulerUtils.getZoneId(schedule.getTimezone()); |
|||
ZonedDateTime zdt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(eventTs), zoneId); |
|||
int dayOfWeek = zdt.toLocalDate().getDayOfWeek().getValue(); |
|||
for (CustomTimeScheduleItem item : schedule.getItems()) { |
|||
if (item.getDayOfWeek() == dayOfWeek) { |
|||
if (item.isEnabled()) { |
|||
long endsOn = item.getEndsOn(); |
|||
if (endsOn == 0) { |
|||
// 24 hours in milliseconds
|
|||
endsOn = 86400000; |
|||
} |
|||
return isActive(eventTs, zoneId, zdt, item.getStartsOn(), endsOn); |
|||
} else { |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private boolean isActive(long eventTs, ZoneId zoneId, ZonedDateTime zdt, long startsOn, long endsOn) { |
|||
long startOfDay = zdt.toLocalDate().atStartOfDay(zoneId).toInstant().toEpochMilli(); |
|||
long msFromStartOfDay = eventTs - startOfDay; |
|||
if (startsOn <= endsOn) { |
|||
return startsOn <= msFromStartOfDay && endsOn > msFromStartOfDay; |
|||
} else { |
|||
return startsOn < msFromStartOfDay || (0 < msFromStartOfDay && msFromStartOfDay < endsOn); |
|||
} |
|||
} |
|||
|
|||
public void clear() { |
|||
clearRepeatingConditionState(); |
|||
clearDurationConditionState(); |
|||
} |
|||
|
|||
private void clearRepeatingConditionState() { |
|||
eventCount = 0L; |
|||
} |
|||
|
|||
private void clearDurationConditionState() { |
|||
firstEventTs = 0L; |
|||
lastEventTs = 0L; |
|||
duration = 0L; |
|||
if (durationCheckFuture != null) { |
|||
durationCheckFuture.cancel(true); |
|||
durationCheckFuture = null; |
|||
} |
|||
} |
|||
|
|||
public boolean isEmpty() { |
|||
return eventCount == 0L && firstEventTs == 0L && lastEventTs == 0L && durationCheckFuture == null; |
|||
} |
|||
|
|||
private AlarmSchedule parseSchedule(String str) { |
|||
ObjectNode json = (ObjectNode) JacksonUtil.toJsonNode(str); |
|||
if (json.isEmpty()) { |
|||
return new AnyTimeSchedule(); // only if valid json, fail otherwise
|
|||
} |
|||
|
|||
if (!json.hasNonNull("type")) { |
|||
// deducting the schedule type
|
|||
AlarmScheduleType type; |
|||
if (json.hasNonNull("daysOfWeek")) { |
|||
type = AlarmScheduleType.SPECIFIC_TIME; |
|||
} else if (json.hasNonNull("items")) { |
|||
type = AlarmScheduleType.CUSTOM; |
|||
} else { |
|||
throw new IllegalArgumentException("Failed to parse alarm schedule from '" + str + "'"); |
|||
} |
|||
json.put("type", type.name()); |
|||
} |
|||
|
|||
return JacksonUtil.treeToValue(json, AlarmSchedule.class); |
|||
} |
|||
|
|||
private Integer getIntValue(AlarmConditionValue<Integer> value) { |
|||
return state.resolveValue(value, entry -> Optional.ofNullable(KvUtil.getLongValue(entry)).map(Long::intValue).orElse(null)); |
|||
} |
|||
|
|||
private long getRequiredDurationInMs() { |
|||
DurationAlarmCondition durationCondition = (DurationAlarmCondition) condition; |
|||
return durationCondition.getUnit().toMillis(state.resolveValue(durationCondition.getValue(), KvUtil::getLongValue)); |
|||
} |
|||
|
|||
private boolean eval(AlarmConditionExpression expression, CalculatedFieldCtx ctx) { |
|||
return state.eval(expression, ctx); |
|||
} |
|||
|
|||
public void setAlarmRule(AlarmRule alarmRule) { |
|||
this.alarmRule = alarmRule; |
|||
this.condition = alarmRule.getCondition(); |
|||
|
|||
// clearing state for other condition types (possibly left from a previous condition type)
|
|||
switch (condition.getType()) { |
|||
case SIMPLE -> { |
|||
clearRepeatingConditionState(); |
|||
clearDurationConditionState(); |
|||
} |
|||
case REPEATING -> { |
|||
clearDurationConditionState(); |
|||
} |
|||
case DURATION -> { |
|||
clearRepeatingConditionState(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public StateInfo getStateInfo() { |
|||
if (condition.getType() == AlarmConditionType.REPEATING) { |
|||
return new StateInfo(eventCount, null); |
|||
} else if (condition.getType() == AlarmConditionType.DURATION) { |
|||
return new StateInfo(null, duration); |
|||
} else { |
|||
return StateInfo.EMPTY; |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return "AlarmRuleState{" + |
|||
"severity=" + severity + |
|||
", condition=" + condition + |
|||
", eventCount=" + eventCount + |
|||
", firstEventTs=" + firstEventTs + |
|||
", lastEventTs=" + lastEventTs + |
|||
", duration=" + duration + |
|||
", durationCheckFuture=" + durationCheckFuture + |
|||
'}'; |
|||
} |
|||
|
|||
public record StateInfo(Long eventCount, Long duration) { |
|||
static final StateInfo EMPTY = new StateInfo(null, null); |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
/** |
|||
* 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.propagation; |
|||
|
|||
import lombok.Data; |
|||
import org.thingsboard.script.api.tbel.TbelCfArg; |
|||
import org.thingsboard.script.api.tbel.TbelCfPropagationArg; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.util.CollectionsUtil; |
|||
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
|
|||
@Data |
|||
public class PropagationArgumentEntry implements ArgumentEntry { |
|||
|
|||
private List<EntityId> propagationEntityIds; |
|||
|
|||
private boolean forceResetPrevious; |
|||
|
|||
public PropagationArgumentEntry(List<EntityId> propagationEntityIds) { |
|||
this.propagationEntityIds = new ArrayList<>(propagationEntityIds); |
|||
} |
|||
|
|||
@Override |
|||
public ArgumentEntryType getType() { |
|||
return ArgumentEntryType.PROPAGATION; |
|||
} |
|||
|
|||
@Override |
|||
public Object getValue() { |
|||
return propagationEntityIds; |
|||
} |
|||
|
|||
@Override |
|||
public boolean updateEntry(ArgumentEntry entry) { |
|||
if (!(entry instanceof PropagationArgumentEntry propagationArgumentEntry)) { |
|||
throw new IllegalArgumentException("Unsupported argument entry type for propagation argument entry: " + entry.getType()); |
|||
} |
|||
if (propagationArgumentEntry.isEmpty()) { |
|||
propagationEntityIds.clear(); |
|||
} else { |
|||
propagationEntityIds = propagationArgumentEntry.getPropagationEntityIds(); |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
@Override |
|||
public boolean isEmpty() { |
|||
return CollectionsUtil.isEmpty(propagationEntityIds); |
|||
} |
|||
|
|||
@Override |
|||
public TbelCfArg toTbelCfArg() { |
|||
return new TbelCfPropagationArg(propagationEntityIds); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,107 @@ |
|||
/** |
|||
* 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.propagation; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import com.google.common.util.concurrent.MoreExecutors; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
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.OutputType; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.service.cf.CalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
|||
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; |
|||
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Map; |
|||
|
|||
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; |
|||
|
|||
public class PropagationCalculatedFieldState extends ScriptCalculatedFieldState { |
|||
|
|||
public PropagationCalculatedFieldState(EntityId entityId) { |
|||
super(entityId); |
|||
} |
|||
|
|||
@Override |
|||
public void setCtx(CalculatedFieldCtx ctx, TbActorRef actorCtx) { |
|||
this.ctx = ctx; |
|||
this.actorCtx = actorCtx; |
|||
this.requiredArguments = new ArrayList<>(ctx.getArgNames()); |
|||
requiredArguments.add(PROPAGATION_CONFIG_ARGUMENT); |
|||
this.readinessStatus = checkReadiness(requiredArguments, arguments); |
|||
if (ctx.isApplyExpressionForResolvedArguments()) { |
|||
this.tbelExpression = ctx.getTbelExpressions().get(ctx.getExpression()); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public CalculatedFieldType getType() { |
|||
return CalculatedFieldType.PROPAGATION; |
|||
} |
|||
|
|||
@Override |
|||
public ListenableFuture<CalculatedFieldResult> performCalculation(Map<String, ArgumentEntry> updatedArgs, CalculatedFieldCtx ctx) { |
|||
ArgumentEntry argumentEntry = arguments.get(PROPAGATION_CONFIG_ARGUMENT); |
|||
if (!(argumentEntry instanceof PropagationArgumentEntry propagationArgumentEntry) || propagationArgumentEntry.isEmpty()) { |
|||
return Futures.immediateFuture(PropagationCalculatedFieldResult.builder().build()); |
|||
} |
|||
if (ctx.isApplyExpressionForResolvedArguments()) { |
|||
return Futures.transform(super.performCalculation(updatedArgs, ctx), telemetryCfResult -> |
|||
PropagationCalculatedFieldResult.builder() |
|||
.propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) |
|||
.result((TelemetryCalculatedFieldResult) telemetryCfResult) |
|||
.build(), |
|||
MoreExecutors.directExecutor()); |
|||
} |
|||
return Futures.immediateFuture(PropagationCalculatedFieldResult.builder() |
|||
.propagationEntityIds(propagationArgumentEntry.getPropagationEntityIds()) |
|||
.result(toTelemetryResult(ctx)) |
|||
.build()); |
|||
} |
|||
|
|||
private TelemetryCalculatedFieldResult toTelemetryResult(CalculatedFieldCtx ctx) { |
|||
Output output = ctx.getOutput(); |
|||
TelemetryCalculatedFieldResult.TelemetryCalculatedFieldResultBuilder telemetryCfBuilder = |
|||
TelemetryCalculatedFieldResult.builder() |
|||
.type(output.getType()) |
|||
.scope(output.getScope()); |
|||
ObjectNode valuesNode = JacksonUtil.newObjectNode(); |
|||
arguments.forEach((outputKey, argumentEntry) -> { |
|||
if (argumentEntry instanceof PropagationArgumentEntry) { |
|||
return; |
|||
} |
|||
if (argumentEntry instanceof SingleValueArgumentEntry singleArgumentEntry) { |
|||
JacksonUtil.addKvEntry(valuesNode, singleArgumentEntry.getKvEntryValue(), outputKey); |
|||
return; |
|||
} |
|||
throw new IllegalArgumentException("Unsupported argument type: " + argumentEntry.getType() + " detected for argument: " + outputKey + ". " + |
|||
"Only Latest telemetry or Attribute arguments supported for 'Arguments Only' propagation mode!"); |
|||
}); |
|||
ObjectNode result = toSimpleResult(output.getType() == OutputType.TIME_SERIES, valuesNode); |
|||
telemetryCfBuilder.result(result); |
|||
return telemetryCfBuilder.build(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,907 @@ |
|||
/** |
|||
* 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 lombok.AllArgsConstructor; |
|||
import lombok.Getter; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.rule.engine.action.TbAlarmResult; |
|||
import org.thingsboard.server.actors.ActorSystemContext; |
|||
import org.thingsboard.server.common.data.AttributeScope; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.alarm.Alarm; |
|||
import org.thingsboard.server.common.data.alarm.AlarmInfo; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.alarm.AlarmStatus; |
|||
import org.thingsboard.server.common.data.alarm.rule.AlarmRule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.DurationAlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate.NumericOperation; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate.StringOperation; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; |
|||
import org.thingsboard.server.common.data.cf.CalculatedField; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; |
|||
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.CurrentOwnerDynamicSourceConfiguration; |
|||
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; |
|||
import org.thingsboard.server.common.data.debug.DebugSettings; |
|||
import org.thingsboard.server.common.data.event.CalculatedFieldDebugEvent; |
|||
import org.thingsboard.server.common.data.event.EventType; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.EventId; |
|||
import org.thingsboard.server.common.data.query.EntityKeyValueType; |
|||
import org.thingsboard.server.controller.AbstractControllerTest; |
|||
import org.thingsboard.server.dao.event.EventDao; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
|
|||
import java.time.Duration; |
|||
import java.time.LocalDate; |
|||
import java.time.LocalDateTime; |
|||
import java.time.ZoneId; |
|||
import java.time.temporal.ChronoUnit; |
|||
import java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.TimeUnit; |
|||
import java.util.function.Consumer; |
|||
import java.util.function.Predicate; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.testcontainers.shaded.org.awaitility.Awaitility.await; |
|||
|
|||
@Slf4j |
|||
@DaoSqlTest |
|||
@TestPropertySource(properties = { |
|||
"actors.alarms.reevaluation_interval=1" |
|||
}) |
|||
public class AlarmRulesTest extends AbstractControllerTest { |
|||
|
|||
@MockitoSpyBean |
|||
private ActorSystemContext actorSystemContext; |
|||
|
|||
@Autowired |
|||
private EventDao eventDao; |
|||
|
|||
private Device device; |
|||
private DeviceId deviceId; |
|||
private EntityId originatorId; |
|||
private EventId latestEventId; |
|||
|
|||
@Before |
|||
public void beforeEach() throws Exception { |
|||
loginTenantAdmin(); |
|||
device = createDevice("Device A", "aaa"); |
|||
deviceId = device.getId(); |
|||
originatorId = deviceId; |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_severityUpdate_clear() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", null, null), |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) |
|||
); |
|||
|
|||
Condition clearRule = new Condition("return temperature <= 25;", null, null); |
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, clearRule); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":100}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isSeverityUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":101}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":20}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCleared()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_simpleConditionExpression() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression(); |
|||
AlarmConditionFilter filter = new AlarmConditionFilter(); |
|||
filter.setArgument("temperature"); |
|||
filter.setValueType(EntityKeyValueType.NUMERIC); |
|||
NumericFilterPredicate predicate = new NumericFilterPredicate(); |
|||
predicate.setOperation(NumericOperation.GREATER_OR_EQUAL); |
|||
AlarmConditionValue<Double> thresholdValue = new AlarmConditionValue<>(); |
|||
thresholdValue.setStaticValue(100.0); |
|||
predicate.setValue(thresholdValue); |
|||
filter.setPredicates(List.of(predicate)); |
|||
simpleExpression.setFilters(List.of(filter)); |
|||
simpleExpression.setOperation(ComplexOperation.AND); |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":100}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_repeatingCondition() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
int eventsCountMajor = 5; |
|||
int eventsCountCritical = 10; |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null), |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
for (int i = 0; i < 4; i++) { |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Thread.sleep(10); |
|||
} |
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionRepeats()).isEqualTo(5); |
|||
}); |
|||
|
|||
for (int i = 0; i < 4; i++) { |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Thread.sleep(10); |
|||
} |
|||
checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { |
|||
assertThat(alarmResult.isUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); |
|||
}); |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isSeverityUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionRepeats()).isEqualTo(10); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_dynamicRepeatingCondition() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
|
|||
Argument eventsCountArgument = new Argument(); |
|||
eventsCountArgument.setRefEntityKey(new ReferencedEntityKey("eventsCount", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
eventsCountArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument, |
|||
"eventsCount", eventsCountArgument |
|||
); |
|||
|
|||
int eventsCount = 5; |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, |
|||
new AlarmConditionValue<>(null, "eventsCount"), null, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"eventsCount\":" + eventsCount + "}"); |
|||
for (int i = 0; i < eventsCount; i++) { |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Thread.sleep(10); |
|||
} |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionRepeats()).isEqualTo(eventsCount); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_durationCondition() throws Exception { |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"powerConsumption", argument |
|||
); |
|||
|
|||
long createDurationMs = 5000L; |
|||
long clearDurationMs = 3000L; |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, createDurationMs) |
|||
); |
|||
Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds", |
|||
arguments, createRules, clearRule); |
|||
postTelemetry(deviceId, "{\"powerConsumption\":3500}"); |
|||
Thread.sleep(createDurationMs - 2000); |
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
|
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); |
|||
}); |
|||
|
|||
postTelemetry(deviceId, "{\"powerConsumption\":2000}"); |
|||
Thread.sleep(clearDurationMs - 2000); |
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
|
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCleared()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); |
|||
assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_dynamicDurationCondition() throws Exception { |
|||
Argument powerConsumptionArgument = new Argument(); |
|||
powerConsumptionArgument.setRefEntityKey(new ReferencedEntityKey("powerConsumption", ArgumentType.TS_LATEST, null)); |
|||
powerConsumptionArgument.setDefaultValue("0"); |
|||
|
|||
Argument durationArgument = new Argument(); |
|||
durationArgument.setRefEntityKey(new ReferencedEntityKey("duration", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
durationArgument.setDefaultValue("-1"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"powerConsumption", powerConsumptionArgument, |
|||
"duration", durationArgument |
|||
); |
|||
|
|||
long createDurationMs = 2000L; |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return powerConsumption >= 3000;", null, null, |
|||
new AlarmConditionValue<Long>(null, "duration"), null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", |
|||
arguments, createRules, null); |
|||
postTelemetry(deviceId, "{\"powerConsumption\":3500}"); |
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}"); |
|||
|
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionDuration()).isBetween(createDurationMs, createDurationMs + 2000); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_currentOwnerArgument() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
|
|||
Argument temperatureThresholdArgument = new Argument(); |
|||
temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); |
|||
temperatureThresholdArgument.setDefaultValue("1000"); |
|||
|
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument, |
|||
"temperatureThreshold", temperatureThresholdArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) |
|||
); |
|||
|
|||
device.setCustomerId(customerId); |
|||
device = doPost("/api/device", device, Device.class); |
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":51}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAndClearAlarm_customerAlarmRule_simpleExpression() throws Exception { |
|||
Argument locationArgument = new Argument(); |
|||
locationArgument.setRefEntityKey(new ReferencedEntityKey("location", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
locationArgument.setDefaultValue("unknown"); |
|||
originatorId = customerId; |
|||
|
|||
Argument locationFilterArgument = new Argument(); |
|||
locationFilterArgument.setRefEntityKey(new ReferencedEntityKey("locationFilter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
locationFilterArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); |
|||
locationFilterArgument.setDefaultValue("None"); |
|||
|
|||
Map<String, Argument> arguments = Map.of( |
|||
"location", locationArgument, |
|||
"locationFilter", locationFilterArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.INDETERMINATE, new Condition(createSimpleExpression( |
|||
"location", StringOperation.CONTAINS, new AlarmConditionValue<>(null, "locationFilter") |
|||
), null, null) |
|||
); |
|||
Condition clearRule = new Condition(createSimpleExpression( |
|||
"location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter") |
|||
), null, null); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(customerId, "New resident", |
|||
arguments, createRules, clearRule); |
|||
|
|||
loginSysAdmin(); |
|||
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"locationFilter\":\"Kyiv\"}"); |
|||
loginTenantAdmin(); |
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Kyiv\"}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
|
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Lviv\"}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCleared()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_dynamicSchedule() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Argument scheduleArgument = new Argument(); |
|||
scheduleArgument.setRefEntityKey(new ReferencedEntityKey("schedule", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
scheduleArgument.setDefaultValue("None"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument, |
|||
"schedule", scheduleArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null, null, |
|||
new AlarmConditionValue<>(null, "schedule")) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
String schedule = """ |
|||
{"timezone":"Europe/Kiev","items":[{"enabled":false,"dayOfWeek":1,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":2,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":3,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":4,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":5,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":6,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":7,"startsOn":0,"endsOn":0}]} |
|||
"""; |
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
|
|||
Thread.sleep(1000); |
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
|
|||
schedule = schedule.replace("\"enabled\":false", "\"enabled\":true"); |
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); |
|||
|
|||
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { |
|||
// checking multiple debug events due to scheduled reevaluation (which also produces debug events)
|
|||
CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() |
|||
.filter(event -> event.getResult() != null) |
|||
.findFirst().orElse(null); |
|||
assertThat(debugEvent).isNotNull(); |
|||
TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeAlarmType() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
|
|||
calculatedField.setName("New alarm type"); |
|||
calculatedField = saveCalculatedField(calculatedField); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeRuleExpression() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Thread.sleep(1000); |
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
|
|||
AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); |
|||
((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression()) |
|||
.setExpression("return temperature >= 50;"); |
|||
calculatedField = saveCalculatedField(calculatedField); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeRequiredEventsCountForRepeatingCondition() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
int eventsCountMajor = 5; |
|||
int eventsCountCritical = 10; |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.MAJOR, new Condition("return temperature >= 50;", eventsCountMajor, null), |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
for (int i = 0; i < eventsCountMajor; i++) { |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Thread.sleep(10); |
|||
} |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionRepeats()).isEqualTo(5); |
|||
}); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); |
|||
assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); |
|||
}); |
|||
|
|||
// decreasing required events count for critical rule
|
|||
AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); |
|||
((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition()) |
|||
.setCount(new AlarmConditionValue<>(6, null)); |
|||
calculatedField = saveCalculatedField(calculatedField); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isSeverityUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeConditionArgumentSource() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
|
|||
Argument temperatureThresholdArgument = new Argument(); |
|||
temperatureThresholdArgument.setRefEntityKey(new ReferencedEntityKey("temperatureThreshold", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
temperatureThresholdArgument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); |
|||
temperatureThresholdArgument.setDefaultValue("100"); |
|||
loginSysAdmin(); |
|||
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":100}"); |
|||
loginTenantAdmin(); |
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); |
|||
|
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument, |
|||
"temperatureThreshold", temperatureThresholdArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Thread.sleep(1000); |
|||
// not created because tenant's threshold 100 is used
|
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
|
|||
((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold") |
|||
.setRefDynamicSourceConfiguration(null); |
|||
// using threshold 50 on device level
|
|||
calculatedField = saveCalculatedField(calculatedField); |
|||
|
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testAlarmDetails() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Argument humidityArgument = new Argument(); |
|||
humidityArgument.setRefEntityKey(new ReferencedEntityKey("humidity", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
humidityArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument, |
|||
"humidity", humidityArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null) |
|||
); |
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm", |
|||
arguments, createRules, null, configuration -> { |
|||
configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( |
|||
"temperature is ${temperature}, humidity is ${humidity}" |
|||
); |
|||
}); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}"); |
|||
|
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) |
|||
.isEqualTo("temperature is 50, humidity is 50"); |
|||
}); |
|||
|
|||
((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( |
|||
"UPDATED temperature is ${temperature}, humidity is ${humidity}" |
|||
); |
|||
calculatedField = saveCalculatedField(calculatedField); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isFalse(); |
|||
assertThat(alarmResult.isUpdated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) |
|||
.isEqualTo("UPDATED temperature is 50, humidity is 50"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateAlarm_scheduleStarted() throws Exception { |
|||
Argument parkingSpotOccupiedArgument = new Argument(); |
|||
parkingSpotOccupiedArgument.setRefEntityKey(new ReferencedEntityKey("parkingSpotOccupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
parkingSpotOccupiedArgument.setDefaultValue("false"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"parkingSpotOccupied", parkingSpotOccupiedArgument |
|||
); |
|||
|
|||
SpecificTimeSchedule schedule = new SpecificTimeSchedule(); |
|||
schedule.setTimezone(ZoneId.systemDefault().getId()); |
|||
schedule.setDaysOfWeek(Set.of(1, 2, 3, 4, 5, 6, 7)); |
|||
long startsOn = Duration.between(LocalDate.now().atStartOfDay(), LocalDateTime.now()) |
|||
.plus(15, ChronoUnit.SECONDS).toMillis(); |
|||
schedule.setStartsOn(startsOn); |
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return parkingSpotOccupied == true;", null, null, null, |
|||
new AlarmConditionValue<>(schedule, null)) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm", |
|||
arguments, createRules, null); |
|||
|
|||
postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}"); |
|||
|
|||
Thread.sleep(10000); |
|||
assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); |
|||
|
|||
await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { |
|||
CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() |
|||
.filter(event -> event.getResult() != null) |
|||
.findFirst().orElse(null); |
|||
assertThat(debugEvent).isNotNull(); |
|||
TbAlarmResult alarmResult = JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testManualClearAlarm() throws Exception { |
|||
Argument temperatureArgument = new Argument(); |
|||
temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
temperatureArgument.setDefaultValue("0"); |
|||
Map<String, Argument> arguments = Map.of( |
|||
"temperature", temperatureArgument |
|||
); |
|||
|
|||
Map<AlarmSeverity, Condition> createRules = Map.of( |
|||
AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) |
|||
); |
|||
|
|||
CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", |
|||
arguments, createRules, null); |
|||
|
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}).getAlarm(); |
|||
|
|||
doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class); |
|||
Thread.sleep(1000); |
|||
postTelemetry(deviceId, "{\"temperature\":50}"); |
|||
checkAlarmResult(calculatedField, alarmResult -> { |
|||
assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId()); |
|||
assertThat(alarmResult.isCreated()).isTrue(); |
|||
assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); |
|||
assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); |
|||
}); |
|||
} |
|||
|
|||
// TODO: MSA tests
|
|||
|
|||
private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer<TbAlarmResult> assertion) { |
|||
return checkAlarmResult(calculatedField, null, assertion); |
|||
} |
|||
|
|||
private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, |
|||
Predicate<TbAlarmResult> waitFor, |
|||
Consumer<TbAlarmResult> assertion) { |
|||
TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.until(() -> getLatestAlarmResult(calculatedField.getId()), result -> |
|||
result != null && (waitFor == null || waitFor.test(result))); |
|||
assertion.accept(alarmResult); |
|||
|
|||
Alarm alarm = alarmResult.getAlarm(); |
|||
assertThat(alarm.getOriginator()).isEqualTo(originatorId); |
|||
assertThat(alarm.getType()).isEqualTo(calculatedField.getName()); |
|||
return alarmResult; |
|||
} |
|||
|
|||
private TbAlarmResult getLatestAlarmResult(CalculatedFieldId calculatedFieldId) { |
|||
List<CalculatedFieldDebugEvent> debugEvents = getDebugEvents(calculatedFieldId, 1); |
|||
if (debugEvents.isEmpty()) { |
|||
return null; |
|||
} |
|||
CalculatedFieldDebugEvent debugEvent = debugEvents.get(0); |
|||
if (debugEvent.getError() != null) { |
|||
throw new RuntimeException(debugEvent.getError()); |
|||
} |
|||
if (debugEvent.getId().equals(latestEventId)) { |
|||
return null; |
|||
} |
|||
latestEventId = debugEvent.getId(); |
|||
return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); |
|||
} |
|||
|
|||
private CalculatedField createAlarmCf(EntityId entityId, |
|||
String alarmType, |
|||
Map<String, Argument> arguments, |
|||
Map<AlarmSeverity, Condition> createConditions, |
|||
Condition clearCondition, |
|||
Consumer<AlarmCalculatedFieldConfiguration>... modifier) { |
|||
Map<AlarmSeverity, AlarmRule> createRules = new HashMap<>(); |
|||
createConditions.forEach((severity, condition) -> { |
|||
createRules.put(severity, toAlarmRule(condition)); |
|||
}); |
|||
AlarmRule clearRule = clearCondition != null ? toAlarmRule(clearCondition) : null; |
|||
|
|||
CalculatedField calculatedField = new CalculatedField(); |
|||
calculatedField.setEntityId(entityId); |
|||
calculatedField.setName(alarmType); |
|||
calculatedField.setType(CalculatedFieldType.ALARM); |
|||
AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); |
|||
configuration.setArguments(arguments); |
|||
configuration.setCreateRules(createRules); |
|||
configuration.setClearRule(clearRule); |
|||
calculatedField.setConfiguration(configuration); |
|||
calculatedField.setDebugSettings(DebugSettings.all()); |
|||
if (modifier.length > 0) { |
|||
modifier[0].accept(configuration); |
|||
} |
|||
CalculatedField savedCalculatedField = saveCalculatedField(calculatedField); |
|||
|
|||
CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.until(() -> getDebugEvents(savedCalculatedField.getId(), 1), |
|||
events -> !events.isEmpty()).get(0); |
|||
latestEventId = debugEvent.getId(); |
|||
return savedCalculatedField; |
|||
} |
|||
|
|||
private AlarmRule toAlarmRule(Condition condition) { |
|||
AlarmRule rule = new AlarmRule(); |
|||
AlarmConditionExpression expression; |
|||
if (condition.getTbelExpression() != null) { |
|||
TbelAlarmConditionExpression tbelExpression = new TbelAlarmConditionExpression(); |
|||
tbelExpression.setExpression(condition.getTbelExpression()); |
|||
expression = tbelExpression; |
|||
} else { |
|||
expression = condition.getSimpleExpression(); |
|||
} |
|||
if (condition.getEventsCount() != null) { |
|||
RepeatingAlarmCondition alarmCondition = new RepeatingAlarmCondition(); |
|||
alarmCondition.setExpression(expression); |
|||
alarmCondition.setCount(condition.getEventsCount()); |
|||
rule.setCondition(alarmCondition); |
|||
} else if (condition.getDuration() != null) { |
|||
DurationAlarmCondition alarmCondition = new DurationAlarmCondition(); |
|||
alarmCondition.setExpression(expression); |
|||
alarmCondition.setUnit(TimeUnit.MILLISECONDS); |
|||
alarmCondition.setValue(condition.getDuration()); |
|||
rule.setCondition(alarmCondition); |
|||
} else { |
|||
SimpleAlarmCondition alarmCondition = new SimpleAlarmCondition(); |
|||
alarmCondition.setExpression(expression); |
|||
rule.setCondition(alarmCondition); |
|||
} |
|||
if (condition.getSchedule() != null) { |
|||
rule.getCondition().setSchedule(condition.getSchedule()); |
|||
} |
|||
return rule; |
|||
} |
|||
|
|||
private SimpleAlarmConditionExpression createSimpleExpression(String argument, StringOperation stringOperation, AlarmConditionValue<String> conditionValue) { |
|||
SimpleAlarmConditionExpression simpleExpression = new SimpleAlarmConditionExpression(); |
|||
AlarmConditionFilter filter = new AlarmConditionFilter(); |
|||
filter.setArgument(argument); |
|||
filter.setValueType(EntityKeyValueType.STRING); |
|||
StringFilterPredicate predicate = new StringFilterPredicate(); |
|||
predicate.setOperation(stringOperation); |
|||
predicate.setValue(conditionValue); |
|||
filter.setPredicates(List.of(predicate)); |
|||
simpleExpression.setFilters(List.of(filter)); |
|||
return simpleExpression; |
|||
} |
|||
|
|||
private List<CalculatedFieldDebugEvent> getDebugEvents(CalculatedFieldId calculatedFieldId, int limit) { |
|||
return eventDao.findLatestEvents(tenantId.getId(), calculatedFieldId.getId(), EventType.DEBUG_CALCULATED_FIELD, limit).stream() |
|||
.map(e -> (CalculatedFieldDebugEvent) e).toList(); |
|||
} |
|||
|
|||
@Getter |
|||
@AllArgsConstructor |
|||
private static final class Condition { |
|||
|
|||
private final String tbelExpression; |
|||
private final SimpleAlarmConditionExpression simpleExpression; |
|||
private AlarmConditionValue<Integer> eventsCount; |
|||
private AlarmConditionValue<Long> duration; |
|||
private AlarmConditionValue<AlarmSchedule> schedule; |
|||
|
|||
private Condition(String tbelExpression, Integer eventsCount, Long durationMs) { |
|||
this.tbelExpression = tbelExpression; |
|||
this.simpleExpression = null; |
|||
if (eventsCount != null) { |
|||
this.eventsCount = new AlarmConditionValue<>(eventsCount, null); |
|||
} |
|||
if (durationMs != null) { |
|||
this.duration = new AlarmConditionValue<>(durationMs, null); |
|||
} |
|||
} |
|||
|
|||
private Condition(SimpleAlarmConditionExpression simpleExpression, Integer eventsCount, Long durationMs) { |
|||
this.tbelExpression = null; |
|||
this.simpleExpression = simpleExpression; |
|||
if (eventsCount != null) { |
|||
this.eventsCount = new AlarmConditionValue<>(eventsCount, null); |
|||
} |
|||
if (durationMs != null) { |
|||
this.duration = new AlarmConditionValue<>(durationMs, null); |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,200 @@ |
|||
/** |
|||
* 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.Test; |
|||
import org.thingsboard.server.common.data.AttributeScope; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.asset.Asset; |
|||
import org.thingsboard.server.common.data.asset.AssetProfile; |
|||
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.CurrentOwnerDynamicSourceConfiguration; |
|||
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.SimpleCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.controller.AbstractControllerTest; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
|
|||
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.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@DaoSqlTest |
|||
public class CalculatedFieldCurrentOwnerTest extends AbstractControllerTest { |
|||
|
|||
public static final int TIMEOUT = 60; |
|||
public static final int POLL_INTERVAL = 1; |
|||
|
|||
@Test |
|||
public void testCreateCFWithCurrentOwner() throws Exception { |
|||
loginTenantAdmin(); |
|||
|
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); |
|||
|
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
|
|||
doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk()); |
|||
|
|||
CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class); |
|||
|
|||
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); |
|||
assertThat(fahrenheitTemp).isNotNull(); |
|||
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105"); |
|||
}); |
|||
|
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":10}"); |
|||
|
|||
await().alias("update telemetry -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); |
|||
assertThat(fahrenheitTemp).isNotNull(); |
|||
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("110"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeOwner() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}"); |
|||
|
|||
loginTenantAdmin(); |
|||
|
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
doPost("/api/customer/" + customerId.getId() + "/device/" + testDevice.getId().getId()).andExpect(status().isOk()); |
|||
|
|||
|
|||
CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(testDevice.getId()), CalculatedField.class); |
|||
|
|||
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); |
|||
assertThat(fahrenheitTemp).isNotNull(); |
|||
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("105"); |
|||
}); |
|||
|
|||
doDelete("/api/customer/device/" + testDevice.getId().getId()).andExpect(status().isOk()); |
|||
|
|||
await().alias("change owner -> perform calculation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ObjectNode fahrenheitTemp = getLatestTelemetry(testDevice.getId(), "result"); |
|||
assertThat(fahrenheitTemp).isNotNull(); |
|||
assertThat(fahrenheitTemp.get("result").get(0).get("value").asText()).isEqualTo("150"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCFWithCurrentOwnerWhenEntityIsProfile() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":50}"); |
|||
|
|||
loginTenantAdmin(); |
|||
|
|||
postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"attrKey\":5}"); |
|||
|
|||
AssetProfile assetProfile = doPost("/api/assetProfile", createAssetProfile("Test Asset Profile"), AssetProfile.class); |
|||
|
|||
Asset asset1 = createAsset("Test asset 1", assetProfile.getId()); |
|||
doPost("/api/customer/" + customerId.getId() + "/asset/" + asset1.getId().getId()).andExpect(status().isOk()); |
|||
|
|||
Asset asset2 = createAsset("Test asset 2", assetProfile.getId()); // owner - TENANT
|
|||
|
|||
CalculatedField savedCalculatedField = doPost("/api/calculatedField", buildCalculatedField(assetProfile.getId()), CalculatedField.class); |
|||
|
|||
await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
// result of asset 1
|
|||
ObjectNode result1 = getLatestTelemetry(asset1.getId(), "result"); |
|||
assertThat(result1).isNotNull(); |
|||
assertThat(result1.get("result").get(0).get("value").asText()).isEqualTo("105"); |
|||
|
|||
// result of asset 2
|
|||
ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result"); |
|||
assertThat(result2).isNotNull(); |
|||
assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("150"); |
|||
}); |
|||
|
|||
doPost("/api/customer/" + customerId.getId() + "/asset/" + asset2.getId().getId()).andExpect(status().isOk()); |
|||
|
|||
await().alias("change asset2 owner -> recalculate state for asset 2").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
// result of asset 2
|
|||
ObjectNode result2 = getLatestTelemetry(asset2.getId(), "result"); |
|||
assertThat(result2).isNotNull(); |
|||
assertThat(result2.get("result").get(0).get("value").asText()).isEqualTo("105"); |
|||
}); |
|||
} |
|||
|
|||
private CalculatedField buildCalculatedField(EntityId entityId) { |
|||
CalculatedField calculatedField = new CalculatedField(); |
|||
calculatedField.setEntityId(entityId); |
|||
calculatedField.setType(CalculatedFieldType.SIMPLE); |
|||
calculatedField.setName("Test Calculated Field"); |
|||
|
|||
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); |
|||
|
|||
Argument argument = new Argument(); |
|||
ReferencedEntityKey refEntityKey = new ReferencedEntityKey("attrKey", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE); |
|||
argument.setRefEntityKey(refEntityKey); |
|||
argument.setRefDynamicSourceConfiguration(new CurrentOwnerDynamicSourceConfiguration()); |
|||
|
|||
config.setArguments(Map.of("a", argument)); |
|||
|
|||
config.setExpression("a + 100"); |
|||
|
|||
Output output = new Output(); |
|||
output.setName("result"); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
config.setOutput(output); |
|||
|
|||
calculatedField.setConfiguration(config); |
|||
return 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); |
|||
} |
|||
|
|||
private Asset createAsset(String name, AssetProfileId assetProfileId) { |
|||
Asset asset = new Asset(); |
|||
asset.setName(name); |
|||
asset.setAssetProfileId(assetProfileId); |
|||
return doPost("/api/asset", asset, Asset.class); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,846 @@ |
|||
/** |
|||
* 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.ArrayNode; |
|||
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.thingsboard.server.common.data.AttributeScope; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.DeviceProfile; |
|||
import org.thingsboard.server.common.data.Tenant; |
|||
import org.thingsboard.server.common.data.User; |
|||
import org.thingsboard.server.common.data.asset.Asset; |
|||
import org.thingsboard.server.common.data.asset.AssetProfile; |
|||
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.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.RelatedEntitiesAggregationCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.debug.DebugSettings; |
|||
import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration; |
|||
import org.thingsboard.server.common.data.device.data.DefaultDeviceTransportConfiguration; |
|||
import org.thingsboard.server.common.data.device.data.DeviceData; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.relation.EntityRelation; |
|||
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
|||
import org.thingsboard.server.common.data.relation.RelationPathLevel; |
|||
import org.thingsboard.server.common.data.relation.RelationTypeGroup; |
|||
import org.thingsboard.server.common.data.rule.RuleChain; |
|||
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.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
import static org.thingsboard.server.cf.CalculatedFieldIntegrationTest.POLL_INTERVAL; |
|||
|
|||
@DaoSqlTest |
|||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) |
|||
public class RelatedEntitiesAggregationCalculatedFieldTest extends AbstractControllerTest { |
|||
|
|||
private Tenant savedTenant; |
|||
|
|||
private DeviceProfile deviceProfile; |
|||
private Device device1; |
|||
private String accessToken1 = "1234567890111"; |
|||
private Device device2; |
|||
private String accessToken2 = "1234567890222"; |
|||
|
|||
private AssetProfile assetProfile; |
|||
private Asset asset; |
|||
|
|||
private final long deduplicationInterval = 5; |
|||
|
|||
@Before |
|||
public void beforeEach() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
updateDefaultTenantProfileConfig(tenantProfileConfig -> { |
|||
tenantProfileConfig.setMinAllowedDeduplicationIntervalInSecForCF(1); |
|||
tenantProfileConfig.setMinAllowedScheduledUpdateIntervalInSecForCF(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"); |
|||
|
|||
deviceProfile = doPost("/api/deviceProfile", createDeviceProfile("Device Profile"), DeviceProfile.class); |
|||
device1 = createDevice("Device 1", deviceProfile.getId(), accessToken1); |
|||
device2 = createDevice("Device 2", deviceProfile.getId(), accessToken2); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":true}"); |
|||
postTelemetry(device2.getId(), "{\"occupied\":false}"); |
|||
|
|||
assetProfile = doPost("/api/assetProfile", createAssetProfile("Asset Profile"), AssetProfile.class); |
|||
asset = createAsset("Asset", assetProfile.getId()); |
|||
|
|||
createEntityRelation(asset.getId(), device1.getId(), "Contains"); |
|||
createEntityRelation(asset.getId(), device2.getId(), "Contains"); |
|||
} |
|||
|
|||
@After |
|||
public void afterTest() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
deleteTenant(savedTenant.getId()); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfOnProfile_checkInitialAggregation() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(assetProfile.getId()); |
|||
|
|||
await().alias("create CF and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
|
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testAddEntityToProfile_checkAggregation() throws Exception { |
|||
createOccupancyCF(assetProfile.getId()); |
|||
|
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
postTelemetry(device4.getId(), "{\"occupied\":true}"); |
|||
|
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
|
|||
await().alias("add entity to profile with no related entities and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ObjectNode occupancy = getLatestTelemetry(asset2.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); |
|||
assertThat(occupancy).isNotNull(); |
|||
assertThat(occupancy.get("freeSpaces").get(0).get("value").isNull()).isTrue(); |
|||
assertThat(occupancy.get("occupiedSpaces").get(0).get("value").isNull()).isTrue(); |
|||
assertThat(occupancy.get("totalSpaces").get(0).get("value").isNull()).isTrue(); |
|||
}); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
await().alias("create relations and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testChangeEntityProfile_checkAggregation() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(assetProfile.getId()); |
|||
|
|||
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
|
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
AssetProfile newAssetProfile = createAssetProfile("New Asset Profile"); |
|||
asset2.setAssetProfileId(newAssetProfile.getId()); |
|||
doPost("/api/asset", asset2, Asset.class); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
await().alias("change profile and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfOnAssetAndNoTelemetryOnDevices_checkDefaultValueUsed() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfAndUpdateTelemetry_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateCfAndRelationToRuleChain_checkAggregation() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
RuleChain ruleChain = new RuleChain(); |
|||
ruleChain.setName("RuleChain"); |
|||
ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); |
|||
postTelemetry(ruleChain.getId(), "{\"occupied\":true}"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), ruleChain.getId(), "Contains"); |
|||
|
|||
createOccupancyCF(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
|
|||
postTelemetry(ruleChain.getId(), "{\"occupied\":true}"); |
|||
|
|||
await().alias("update telemetry on rule chain and no aggregation performed").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteCf_checkNoAggregation() throws Exception { |
|||
CalculatedField cf = createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
doDelete("/api/calculatedField/" + cf.getId().getId().toString()) |
|||
.andExpect(status().isOk()); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("delete cf and update telemetry and no aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateTelemetry_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("update telemetry -> no changes").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(this::checkInitialCalculationValues); |
|||
|
|||
postTelemetry(device2.getId(), "{\"occupied\":false}"); |
|||
|
|||
await().alias("create CF and perform initial calculation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteTelemetry_checkAggregationWithPreviousValuesOrDefault() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
long currentTime = System.currentTimeMillis(); |
|||
long firstTs = currentTime - 10; |
|||
long secondTs = currentTime - 10; |
|||
long thirdTs = currentTime - 5; |
|||
postTelemetry(device3.getId(), "{\"ts\": " + firstTs + ", \"values\": {\"occupied\":true}}"); |
|||
postTelemetry(device4.getId(), "{\"ts\": " + secondTs + ", \"values\": {\"occupied\":true}}"); |
|||
postTelemetry(device3.getId(), "{\"ts\": " + thirdTs + ", \"values\": {\"occupied\":true}}"); |
|||
|
|||
createOccupancyCF(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device3.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + thirdTs + "&endTs=" + thirdTs + 1, String.class); |
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device4.getId() + "/timeseries/delete?keys=occupied&deleteAllDataForKeys=false&rewriteLatestIfDeleted=true&deleteLatest=true&startTs=" + secondTs + "&endTs=" + secondTs + 1, String.class); |
|||
|
|||
await().alias("delete latest telemetry and perform aggregation with previous or default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteAttr_checkAggregationWithDefault() throws Exception { |
|||
Asset asset2 = createAsset("Asset 2", assetProfile.getId()); |
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
Device device4 = createDevice("Device 4", "1234567890444"); |
|||
|
|||
createEntityRelation(asset2.getId(), device3.getId(), "Contains"); |
|||
createEntityRelation(asset2.getId(), device4.getId(), "Contains"); |
|||
|
|||
postAttributes(device3.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); |
|||
postAttributes(device4.getId(), AttributeScope.SERVER_SCOPE, "{\"occupied\":true}"); |
|||
|
|||
createOccupancyCFWithAttr(asset2.getId()); |
|||
|
|||
await().alias("create CF and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
|
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device3.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); |
|||
doDelete("/api/plugins/telemetry/DEVICE/" + device4.getUuidId() + "/SERVER_SCOPE?keys=occupied", String.class); |
|||
|
|||
await().alias("delete attribute and perform aggregation with default values").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset2.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testCreateRelation_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
Device device3 = createDevice("Device 3", deviceProfile.getId(), "1234567890333"); |
|||
|
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
createEntityRelation(asset.getId(), device3.getId(), "Contains"); |
|||
|
|||
await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "2", |
|||
"totalSpaces", "3" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteRelation_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
deleteEntityRelation(new EntityRelation(asset.getId(), device1.getId(), "Contains", RelationTypeGroup.COMMON)); |
|||
|
|||
await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteEntityByRelation_checkAggregation() throws Exception { |
|||
createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
doDelete("/api/device/" + device1.getId()).andExpect(status().isOk()); |
|||
|
|||
await().alias("create relation and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "1", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateRelationPath_checkAggregation() throws Exception { |
|||
CalculatedField cf = createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
Device device3 = createDevice("Device 3", "1234567890333"); |
|||
createEntityRelation(asset.getId(), device3.getId(), "Has"); |
|||
postTelemetry(device3.getId(), "{\"occupied\":true}"); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
configuration.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, "Has")); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update relation path and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "0", |
|||
"occupiedSpaces", "1", |
|||
"totalSpaces", "1" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateArguments_checkAggregation() throws Exception { |
|||
CalculatedField cf = createOccupancyCF(asset.getId()); |
|||
checkInitialCalculation(); |
|||
|
|||
postTelemetry(device1.getId(), "{\"occupiedStatus\":false}"); |
|||
postTelemetry(device2.getId(), "{\"occupiedStatus\":false}"); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("oc", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("false"); |
|||
configuration.setArguments(Map.of("oc", argument)); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update arguments and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of( |
|||
"freeSpaces", "2", |
|||
"occupiedSpaces", "0", |
|||
"totalSpaces", "2" |
|||
)); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateMetrics_checkAggregation() throws Exception { |
|||
postTelemetry(device1.getId(), "{\"temperature\":24.2}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":19.6}"); |
|||
CalculatedField cf = createAvgTemperatureCF(asset.getId()); |
|||
|
|||
await().alias("create avg temp cf and perform initial aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
AggMetric aggMetric = new AggMetric(); |
|||
aggMetric.setInput(new AggKeyInput("temp")); |
|||
aggMetric.setFilter("return temp < 100;"); |
|||
aggMetric.setFunction(AggFunction.MAX); |
|||
configuration.setMetrics(Map.of("maxTemperature", aggMetric)); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update metrics and perform aggregation").atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("maxTemperature", "24")); |
|||
}); |
|||
|
|||
postTelemetry(device1.getId(), "{\"temperature\":101.3}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":25.8}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation") |
|||
.atLeast(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.atMost(TIMEOUT, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("maxTemperature", "26")); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateOutput_checkAggregation() throws Exception { |
|||
postTelemetry(device1.getId(), "{\"temperature\":24.2}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":19.6}"); |
|||
CalculatedField cf = createAvgTemperatureCF(asset.getId()); |
|||
|
|||
await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
Output output = new Output(); |
|||
output.setType(OutputType.ATTRIBUTES); |
|||
output.setScope(AttributeScope.SERVER_SCOPE); |
|||
configuration.setOutput(output); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update output and perform aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
ArrayNode avgTemperature = getServerAttributes(asset.getId(), "avgTemperature"); |
|||
assertThat(avgTemperature).isNotNull(); |
|||
assertThat(avgTemperature.get(0)).isNotNull(); |
|||
assertThat(avgTemperature.get(0).get("value").asText()).isEqualTo("24.2"); |
|||
}); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateDeduplicationInterval_checkAggregationNotExecutedUntilDeduplicationInterval() throws Exception { |
|||
postTelemetry(device1.getId(), "{\"temperature\":24.2}"); |
|||
postTelemetry(device2.getId(), "{\"temperature\":19.6}"); |
|||
CalculatedField cf = createAvgTemperatureCF(asset.getId()); |
|||
|
|||
await().alias("create avg temp cf and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
var configuration = (RelatedEntitiesAggregationCalculatedFieldConfiguration) cf.getConfiguration(); |
|||
configuration.setDeduplicationIntervalInSec(2 * deduplicationInterval); |
|||
saveCalculatedField(cf); |
|||
|
|||
await().alias("update deduplication interval and perform aggregation").atMost(deduplicationInterval / 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "24")); |
|||
}); |
|||
|
|||
postTelemetry(device2.getId(), "{\"temperature\":32.1}"); |
|||
|
|||
await().alias("update telemetry and perform aggregation").atMost(2 * deduplicationInterval + 10, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(() -> { |
|||
verifyTelemetry(asset.getId(), Map.of("avgTemperature", "28")); |
|||
}); |
|||
} |
|||
|
|||
private void checkInitialCalculation() { |
|||
await().alias("create CF and perform initial aggregation").atMost(deduplicationInterval * 2, TimeUnit.SECONDS) |
|||
.pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) |
|||
.untilAsserted(this::checkInitialCalculationValues); |
|||
} |
|||
|
|||
private void checkInitialCalculationValues() throws Exception { |
|||
ObjectNode occupancy = getLatestTelemetry(asset.getId(), "freeSpaces", "occupiedSpaces", "totalSpaces"); |
|||
assertThat(occupancy).isNotNull(); |
|||
assertThat(occupancy.get("freeSpaces").get(0).get("value").asText()).isEqualTo("1"); |
|||
assertThat(occupancy.get("occupiedSpaces").get(0).get("value").asText()).isEqualTo("1"); |
|||
assertThat(occupancy.get("totalSpaces").get(0).get("value").asText()).isEqualTo("2"); |
|||
} |
|||
|
|||
private CalculatedField createAvgTemperatureCF(EntityId entityId) { |
|||
Map<String, Argument> arguments = new HashMap<>(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("20"); |
|||
arguments.put("temp", argument); |
|||
|
|||
Map<String, AggMetric> aggMetrics = new HashMap<>(); |
|||
|
|||
AggMetric avgMetric = new AggMetric(); |
|||
avgMetric.setFunction(AggFunction.AVG); |
|||
avgMetric.setFilter("return temp >= 20;"); |
|||
avgMetric.setInput(new AggKeyInput("temp")); |
|||
aggMetrics.put("avgTemperature", avgMetric); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
return createAggCf("Average temperature", entityId, |
|||
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), |
|||
arguments, |
|||
aggMetrics, |
|||
output); |
|||
} |
|||
|
|||
private CalculatedField createOccupancyCF(EntityId entityId) { |
|||
Map<String, Argument> arguments = new HashMap<>(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("false"); |
|||
arguments.put("oc", argument); |
|||
|
|||
Map<String, AggMetric> aggMetrics = new HashMap<>(); |
|||
|
|||
AggMetric freeSpaces = new AggMetric(); |
|||
freeSpaces.setFunction(AggFunction.COUNT); |
|||
freeSpaces.setFilter("return oc == false;"); |
|||
freeSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("freeSpaces", freeSpaces); |
|||
|
|||
AggMetric occupiedSpaces = new AggMetric(); |
|||
occupiedSpaces.setFunction(AggFunction.COUNT); |
|||
occupiedSpaces.setFilter("return oc == true;"); |
|||
occupiedSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("occupiedSpaces", occupiedSpaces); |
|||
|
|||
AggMetric totalSpaces = new AggMetric(); |
|||
totalSpaces.setFunction(AggFunction.COUNT); |
|||
totalSpaces.setInput(new AggFunctionInput("return 1;")); |
|||
aggMetrics.put("totalSpaces", totalSpaces); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
return createAggCf("Occupied spaces", entityId, |
|||
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), |
|||
arguments, |
|||
aggMetrics, |
|||
output); |
|||
} |
|||
|
|||
private CalculatedField createOccupancyCFWithAttr(EntityId entityId) { |
|||
Map<String, Argument> arguments = new HashMap<>(); |
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("occupied", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
|||
argument.setDefaultValue("false"); |
|||
arguments.put("oc", argument); |
|||
|
|||
Map<String, AggMetric> aggMetrics = new HashMap<>(); |
|||
|
|||
AggMetric freeSpaces = new AggMetric(); |
|||
freeSpaces.setFunction(AggFunction.COUNT); |
|||
freeSpaces.setFilter("return oc == false;"); |
|||
freeSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("freeSpaces", freeSpaces); |
|||
|
|||
AggMetric occupiedSpaces = new AggMetric(); |
|||
occupiedSpaces.setFunction(AggFunction.COUNT); |
|||
occupiedSpaces.setFilter("return oc == true;"); |
|||
occupiedSpaces.setInput(new AggKeyInput("oc")); |
|||
aggMetrics.put("occupiedSpaces", occupiedSpaces); |
|||
|
|||
AggMetric totalSpaces = new AggMetric(); |
|||
totalSpaces.setFunction(AggFunction.COUNT); |
|||
totalSpaces.setInput(new AggFunctionInput("return 1;")); |
|||
aggMetrics.put("totalSpaces", totalSpaces); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.TIME_SERIES); |
|||
output.setDecimalsByDefault(0); |
|||
|
|||
return createAggCf("Occupied spaces", entityId, |
|||
new RelationPathLevel(EntitySearchDirection.FROM, "Contains"), |
|||
arguments, |
|||
aggMetrics, |
|||
output); |
|||
} |
|||
|
|||
private CalculatedField createAggCf(String name, |
|||
EntityId entityId, |
|||
RelationPathLevel relation, |
|||
Map<String, Argument> inputs, |
|||
Map<String, AggMetric> metrics, |
|||
Output output) { |
|||
CalculatedField calculatedField = new CalculatedField(); |
|||
calculatedField.setName(name); |
|||
calculatedField.setEntityId(entityId); |
|||
calculatedField.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); |
|||
|
|||
RelatedEntitiesAggregationCalculatedFieldConfiguration configuration = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); |
|||
configuration.setRelation(relation); |
|||
configuration.setArguments(inputs); |
|||
configuration.setDeduplicationIntervalInSec(deduplicationInterval); |
|||
configuration.setScheduledUpdateInterval(10); |
|||
configuration.setMetrics(metrics); |
|||
configuration.setOutput(output); |
|||
|
|||
calculatedField.setConfiguration(configuration); |
|||
calculatedField.setDebugSettings(DebugSettings.all()); |
|||
return saveCalculatedField(calculatedField); |
|||
} |
|||
|
|||
private Device createDevice(String name, DeviceProfileId deviceProfileId, String accessToken) { |
|||
Device device = new Device(); |
|||
device.setName(name); |
|||
device.setDeviceProfileId(deviceProfileId); |
|||
DeviceData deviceData = new DeviceData(); |
|||
deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); |
|||
deviceData.setConfiguration(new DefaultDeviceConfiguration()); |
|||
device.setDeviceData(deviceData); |
|||
return doPost("/api/device?accessToken=" + accessToken, device, Device.class); |
|||
} |
|||
|
|||
private Asset createAsset(String name, AssetProfileId assetProfileId) { |
|||
Asset asset = new Asset(); |
|||
asset.setName(name); |
|||
asset.setAssetProfileId(assetProfileId); |
|||
return doPost("/api/asset", asset, Asset.class); |
|||
} |
|||
|
|||
private void verifyTelemetry(EntityId entityId, Map<String, String> expectedResults) throws Exception { |
|||
ObjectNode result = getLatestTelemetry(entityId, expectedResults.keySet().toArray(new String[0])); |
|||
assertThat(result).isNotNull(); |
|||
expectedResults.forEach((key, value) -> assertThat(result.get(key).get(0).get("value").asText()).isEqualTo(value)); |
|||
} |
|||
|
|||
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); |
|||
} |
|||
|
|||
private ArrayNode getServerAttributes(EntityId entityId, String... keys) throws Exception { |
|||
return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/attributes/SERVER_SCOPE?keys=" + String.join(",", keys), ArrayNode.class); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.script.api.tbel.TbelCfArg; |
|||
import org.thingsboard.script.api.tbel.TbelCfPropagationArg; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
public class PropagationArgumentEntryTest { |
|||
|
|||
private final AssetId ENTITY_1_ID = new AssetId(UUID.fromString("b0a8637d-6d67-43d5-a483-c0e391afe805")); |
|||
private final AssetId ENTITY_2_ID = new AssetId(UUID.fromString("7bd85073-ded5-414f-a2ef-bd56ad3dbf6a")); |
|||
private final AssetId ENTITY_3_ID = new AssetId(UUID.fromString("d64f3e51-2ec2-472f-b475-b095ef8bdc70")); |
|||
|
|||
private PropagationArgumentEntry entry; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
List<EntityId> propagationEntityIds = new ArrayList<>(); |
|||
propagationEntityIds.add(ENTITY_1_ID); |
|||
propagationEntityIds.add(ENTITY_2_ID); |
|||
entry = new PropagationArgumentEntry(propagationEntityIds); |
|||
} |
|||
|
|||
@Test |
|||
void testArgumentEntryType() { |
|||
assertThat(entry.getType()).isEqualTo(ArgumentEntryType.PROPAGATION); |
|||
} |
|||
|
|||
@Test |
|||
void testIsEmpty() { |
|||
PropagationArgumentEntry emptyEntry = new PropagationArgumentEntry(List.of()); |
|||
assertThat(emptyEntry.isEmpty()).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void testGetValueReturnsPropagationIds() { |
|||
assertThat(entry.getValue()).isInstanceOf(List.class); |
|||
@SuppressWarnings("unchecked") |
|||
List<AssetId> value = (List<AssetId>) entry.getValue(); |
|||
assertThat(value).containsExactly(ENTITY_1_ID, ENTITY_2_ID); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenSingleEntryPassed() { |
|||
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Unsupported argument entry type for propagation argument entry: SINGLE_VALUE"); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenRollingEntryPassed() { |
|||
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Unsupported argument entry type for propagation argument entry: TS_ROLLING"); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryReplacesWithNewIds() { |
|||
var newIds = new ArrayList<EntityId>(List.of(ENTITY_3_ID, ENTITY_1_ID)); |
|||
var updated = new PropagationArgumentEntry(newIds); |
|||
|
|||
boolean changed = entry.updateEntry(updated); |
|||
|
|||
assertThat(changed).isTrue(); |
|||
assertThat(entry.getPropagationEntityIds()).containsExactlyElementsOf(newIds); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryClearsWhenNewEntryIsEmpty() { |
|||
var updatedEmpty = new PropagationArgumentEntry(List.of()); |
|||
|
|||
boolean changed = entry.updateEntry(updatedEmpty); |
|||
|
|||
assertThat(changed).isTrue(); |
|||
assertThat(entry.getPropagationEntityIds()).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
@SuppressWarnings("unchecked") |
|||
void testToTbelCfArgWithValues() { |
|||
TbelCfArg arg = entry.toTbelCfArg(); |
|||
assertThat(arg).isInstanceOf(TbelCfPropagationArg.class); |
|||
|
|||
TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) arg; |
|||
assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class); |
|||
assertThat((List<EntityId>) tbelCfPropagationArg.getValue()).containsExactly(ENTITY_1_ID, ENTITY_2_ID); |
|||
} |
|||
|
|||
|
|||
@Test |
|||
@SuppressWarnings("unchecked") |
|||
void testToTbelCfArgWithEmptyValues() { |
|||
var empty = new PropagationArgumentEntry(List.of()); |
|||
TbelCfArg emptyArg = empty.toTbelCfArg(); |
|||
assertThat(emptyArg).isInstanceOf(TbelCfPropagationArg.class); |
|||
|
|||
TbelCfPropagationArg tbelCfPropagationArg = (TbelCfPropagationArg) emptyArg; |
|||
assertThat(tbelCfPropagationArg.getValue()).isInstanceOf(List.class); |
|||
assertThat((List<EntityId>) tbelCfPropagationArg.getValue()).isEmpty(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,249 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import io.micrometer.core.instrument.simple.SimpleMeterRegistry; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.test.context.bean.override.mockito.MockitoBean; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.script.api.tbel.DefaultTbelInvokeService; |
|||
import org.thingsboard.script.api.tbel.TbelInvokeService; |
|||
import org.thingsboard.server.actors.ActorSystemContext; |
|||
import org.thingsboard.server.common.data.AttributeScope; |
|||
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.CalculatedFieldConfiguration; |
|||
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.PropagationCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
|||
import org.thingsboard.server.common.data.relation.EntityRelation; |
|||
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
|||
import org.thingsboard.server.common.data.relation.RelationPathLevel; |
|||
import org.thingsboard.server.common.stats.DefaultStatsFactory; |
|||
import org.thingsboard.server.dao.usagerecord.ApiLimitService; |
|||
import org.thingsboard.server.service.cf.PropagationCalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.TelemetryCalculatedFieldResult; |
|||
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationArgumentEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.propagation.PropagationCalculatedFieldState; |
|||
|
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.ExecutionException; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.thingsboard.server.common.data.cf.configuration.PropagationCalculatedFieldConfiguration.PROPAGATION_CONFIG_ARGUMENT; |
|||
|
|||
@SpringBootTest(classes = {SimpleMeterRegistry.class, DefaultStatsFactory.class, DefaultTbelInvokeService.class}) |
|||
public class PropagationCalculatedFieldStateTest { |
|||
|
|||
private static final String TEMPERATURE_ARGUMENT_NAME = "t"; |
|||
private static final String TEST_RESULT_EXPRESSION_KEY = "testResult"; |
|||
private static final double TEMPERATURE_VALUE = 12.5; |
|||
|
|||
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("6c3513cb-85e7-4510-8746-1ba01859a8ce")); |
|||
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("be960a50-c029-4698-b2ec-c56a543c561c")); |
|||
private final AssetId ASSET_ID_1 = new AssetId(UUID.fromString("d26f0e5b-7d7d-4a61-9f5e-08ab97b30734")); |
|||
private final AssetId ASSET_ID_2 = new AssetId(UUID.fromString("1933a317-4df5-4d36-9800-68aded74579b")); |
|||
|
|||
private final SingleValueArgumentEntry singleValueArgEntry = |
|||
new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", TEMPERATURE_VALUE), 99L); |
|||
|
|||
private final PropagationArgumentEntry propagationArgEntry = |
|||
new PropagationArgumentEntry(new ArrayList<>(List.of(ASSET_ID_2, ASSET_ID_1))); |
|||
|
|||
private PropagationCalculatedFieldState state; |
|||
private CalculatedFieldCtx ctx; |
|||
|
|||
@Autowired |
|||
private TbelInvokeService tbelInvokeService; |
|||
|
|||
@MockitoBean |
|||
private ApiLimitService apiLimitService; |
|||
|
|||
@MockitoBean |
|||
private ActorSystemContext actorSystemContext; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
when(actorSystemContext.getTbelInvokeService()).thenReturn(tbelInvokeService); |
|||
when(actorSystemContext.getApiLimitService()).thenReturn(apiLimitService); |
|||
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); |
|||
} |
|||
|
|||
void initCtxAndState(boolean applyExpressionToResolvedArguments) { |
|||
ctx = new CalculatedFieldCtx(getCalculatedField(applyExpressionToResolvedArguments), actorSystemContext); |
|||
ctx.init(); |
|||
|
|||
state = new PropagationCalculatedFieldState(ctx.getEntityId()); |
|||
state.setCtx(ctx, null); |
|||
state.init(); |
|||
} |
|||
|
|||
@Test |
|||
void testType() { |
|||
initCtxAndState(false); |
|||
assertThat(state.getType()).isEqualTo(CalculatedFieldType.PROPAGATION); |
|||
} |
|||
|
|||
@Test |
|||
void testInitAddsRequiredArgument() { |
|||
initCtxAndState(false); |
|||
assertThat(state.getRequiredArguments()).containsExactlyInAnyOrder(TEMPERATURE_ARGUMENT_NAME, PROPAGATION_CONFIG_ARGUMENT); |
|||
} |
|||
|
|||
@Test |
|||
void testIsReadyReturnFalseWhenNoArgumentsSet() { |
|||
initCtxAndState(false); |
|||
assertThat(state.isReady()).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void testIsReadyWhenPropagationArgIsNull() { |
|||
initCtxAndState(false); |
|||
state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry), ctx); |
|||
assertThat(state.isReady()).isFalse(); |
|||
assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); |
|||
} |
|||
|
|||
@Test |
|||
void testIsReadyWhenPropagationArgIsEmpty() { |
|||
initCtxAndState(false); |
|||
state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, |
|||
PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())), ctx); |
|||
assertThat(state.isReady()).isFalse(); |
|||
assertThat(state.getReadinessStatus().errorMsg()).contains(PROPAGATION_CONFIG_ARGUMENT); |
|||
} |
|||
|
|||
@Test |
|||
void testIsReadyWhenPropagationArgHasEntities() { |
|||
initCtxAndState(false); |
|||
state.update(Map.of(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry, PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry), ctx); |
|||
assertThat(state.isReady()).isTrue(); |
|||
assertThat(state.getReadinessStatus().errorMsg()).isNull(); |
|||
} |
|||
|
|||
|
|||
@Test |
|||
void testPerformCalculationWithEmptyPropagationArg() throws Exception { |
|||
initCtxAndState(false); |
|||
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, new PropagationArgumentEntry(Collections.emptyList())); |
|||
|
|||
PropagationCalculatedFieldResult result = performCalculation(); |
|||
|
|||
assertThat(result).isNotNull(); |
|||
assertThat(result.isEmpty()).isTrue(); |
|||
assertThat(result.getPropagationEntityIds()).isNullOrEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void testPerformCalculationWithArgumentsOnlyMode() throws Exception { |
|||
initCtxAndState(false); |
|||
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); |
|||
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); |
|||
|
|||
PropagationCalculatedFieldResult propagationResult = performCalculation(); |
|||
|
|||
assertThat(propagationResult).isNotNull(); |
|||
assertThat(propagationResult.isEmpty()).isFalse(); |
|||
assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); |
|||
|
|||
TelemetryCalculatedFieldResult result = propagationResult.getResult(); |
|||
assertThat(result).isNotNull(); |
|||
assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); |
|||
assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); |
|||
|
|||
ObjectNode expectedNode = JacksonUtil.newObjectNode(); |
|||
JacksonUtil.addKvEntry(expectedNode, singleValueArgEntry.getKvEntryValue(), TEMPERATURE_ARGUMENT_NAME); |
|||
|
|||
assertThat(result.getResult()).isEqualTo(expectedNode); |
|||
} |
|||
|
|||
@Test |
|||
void testPerformCalculationWithExpressionResultMode() throws Exception { |
|||
initCtxAndState(true); |
|||
state.getArguments().put(PROPAGATION_CONFIG_ARGUMENT, propagationArgEntry); |
|||
state.getArguments().put(TEMPERATURE_ARGUMENT_NAME, singleValueArgEntry); |
|||
|
|||
PropagationCalculatedFieldResult propagationResult = performCalculation(); |
|||
|
|||
assertThat(propagationResult).isNotNull(); |
|||
assertThat(propagationResult.isEmpty()).isFalse(); |
|||
assertThat(propagationResult.getPropagationEntityIds()).containsExactly(ASSET_ID_2, ASSET_ID_1); |
|||
|
|||
TelemetryCalculatedFieldResult result = propagationResult.getResult(); |
|||
assertThat(result).isNotNull(); |
|||
assertThat(result.getType()).isEqualTo(OutputType.ATTRIBUTES); |
|||
assertThat(result.getScope()).isEqualTo(AttributeScope.SERVER_SCOPE); |
|||
|
|||
ObjectNode expectedNode = JacksonUtil.newObjectNode(); |
|||
expectedNode.put(TEST_RESULT_EXPRESSION_KEY, TEMPERATURE_VALUE * 2); |
|||
|
|||
assertThat(result.getResult()).isEqualTo(expectedNode); |
|||
} |
|||
|
|||
private CalculatedField getCalculatedField(boolean applyExpressionToResolvedArguments) { |
|||
CalculatedField calculatedField = new CalculatedField(); |
|||
calculatedField.setTenantId(TENANT_ID); |
|||
calculatedField.setEntityId(DEVICE_ID); |
|||
calculatedField.setType(CalculatedFieldType.PROPAGATION); |
|||
calculatedField.setName("Test Propagation CF"); |
|||
calculatedField.setConfigurationVersion(1); |
|||
calculatedField.setConfiguration(getCalculatedFieldConfig(applyExpressionToResolvedArguments)); |
|||
calculatedField.setVersion(1L); |
|||
return calculatedField; |
|||
} |
|||
|
|||
private CalculatedFieldConfiguration getCalculatedFieldConfig(boolean applyExpressionToResolvedArguments) { |
|||
var config = new PropagationCalculatedFieldConfiguration(); |
|||
|
|||
config.setRelation(new RelationPathLevel(EntitySearchDirection.TO, EntityRelation.CONTAINS_TYPE)); |
|||
config.setApplyExpressionToResolvedArguments(applyExpressionToResolvedArguments); |
|||
|
|||
Argument temperatureArg = new Argument(); |
|||
ReferencedEntityKey tempKey = new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null); |
|||
temperatureArg.setRefEntityKey(tempKey); |
|||
|
|||
config.setArguments(Map.of(TEMPERATURE_ARGUMENT_NAME, temperatureArg)); |
|||
config.setExpression("{" + TEST_RESULT_EXPRESSION_KEY + ": " + TEMPERATURE_ARGUMENT_NAME + " * 2}"); |
|||
|
|||
Output output = new Output(); |
|||
output.setType(OutputType.ATTRIBUTES); |
|||
output.setScope(AttributeScope.SERVER_SCOPE); |
|||
config.setOutput(output); |
|||
|
|||
return config; |
|||
} |
|||
|
|||
private PropagationCalculatedFieldResult performCalculation() throws ExecutionException, InterruptedException { |
|||
return (PropagationCalculatedFieldResult) state.performCalculation(Collections.emptyMap(), ctx).get(); |
|||
} |
|||
} |
|||
@ -0,0 +1,100 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; |
|||
import org.thingsboard.server.common.data.kv.LongDataEntry; |
|||
import org.thingsboard.server.service.cf.ctx.state.aggregation.RelatedEntitiesArgumentEntry; |
|||
|
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
public class RelatedEntitiesArgumentEntryTest { |
|||
|
|||
private RelatedEntitiesArgumentEntry entry; |
|||
|
|||
private final DeviceId device1 = new DeviceId(UUID.fromString("1984e5f4-9ff0-4187-84ae-e4438bba4c8a")); |
|||
private final DeviceId device2 = new DeviceId(UUID.fromString("937fc062-1a9d-438f-aa22-55a93fc908b7")); |
|||
|
|||
private final long ts = System.currentTimeMillis(); |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
Map<EntityId, ArgumentEntry> aggInputs = new HashMap<>(); |
|||
aggInputs.put(device1, new SingleValueArgumentEntry(device1, new BasicTsKvEntry(ts - 100, new LongDataEntry("key", 12L), 1L))); |
|||
aggInputs.put(device2, new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 150, new LongDataEntry("key", 16L), 6L))); |
|||
|
|||
entry = new RelatedEntitiesArgumentEntry(aggInputs, false); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenNotAggEntryPassed() { |
|||
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("Unsupported argument entry type for aggregation argument entry: " + ArgumentEntryType.TS_ROLLING); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenAggArgumentEntryPasser() { |
|||
DeviceId device3 = new DeviceId(UUID.randomUUID()); |
|||
DeviceId device4 = new DeviceId(UUID.randomUUID()); |
|||
|
|||
RelatedEntitiesArgumentEntry relatedEntitiesArgumentEntry = new RelatedEntitiesArgumentEntry(Map.of( |
|||
device3, new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 16L), 13L)), |
|||
device4, new SingleValueArgumentEntry(device4, new BasicTsKvEntry(ts - 60, new LongDataEntry("key", 23L), 7L)) |
|||
), false); |
|||
|
|||
assertThat(entry.updateEntry(relatedEntitiesArgumentEntry)).isTrue(); |
|||
|
|||
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs(); |
|||
assertThat(aggInputs.size()).isEqualTo(4); |
|||
assertThat(aggInputs.get(device3)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device3)); |
|||
assertThat(aggInputs.get(device4)).isEqualTo(relatedEntitiesArgumentEntry.getEntityInputs().get(device4)); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndNoEntriesById() { |
|||
DeviceId device3 = new DeviceId(UUID.randomUUID()); |
|||
|
|||
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device3, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); |
|||
|
|||
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); |
|||
|
|||
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs(); |
|||
assertThat(aggInputs.size()).isEqualTo(3); |
|||
assertThat(aggInputs.get(device3)).isEqualTo(singleEntityArgumentEntry); |
|||
} |
|||
|
|||
@Test |
|||
void testUpdateEntryWhenSingleValueArgumentEntryPassedAndEntryByIdExist() { |
|||
SingleValueArgumentEntry singleEntityArgumentEntry = new SingleValueArgumentEntry(device2, new BasicTsKvEntry(ts - 50, new LongDataEntry("key", 18L), 10L)); |
|||
|
|||
assertThat(entry.updateEntry(singleEntityArgumentEntry)).isTrue(); |
|||
|
|||
Map<EntityId, ArgumentEntry> aggInputs = entry.getEntityInputs(); |
|||
assertThat(aggInputs.size()).isEqualTo(2); |
|||
assertThat(aggInputs.get(device2)).isEqualTo(singleEntityArgumentEntry); |
|||
} |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue