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