88 changed files with 4423 additions and 323 deletions
@ -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.CalculatedFieldId; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.msg.MsgType; |
||||
|
import org.thingsboard.server.common.msg.ToCalculatedFieldSystemMsg; |
||||
|
|
||||
|
@Data |
||||
|
public class CalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { |
||||
|
|
||||
|
private final TenantId tenantId; |
||||
|
private final CalculatedFieldId cfId; |
||||
|
|
||||
|
@Override |
||||
|
public MsgType getMsgType() { |
||||
|
return MsgType.CF_DYNAMIC_ARGUMENTS_REFRESH_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.CalculatedFieldId; |
||||
|
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 |
||||
|
public class EntityCalculatedFieldDynamicArgumentsRefreshMsg implements ToCalculatedFieldSystemMsg { |
||||
|
|
||||
|
private final TenantId tenantId; |
||||
|
private final CalculatedFieldId cfId; |
||||
|
private final TbCallback callback; |
||||
|
|
||||
|
@Override |
||||
|
public MsgType getMsgType() { |
||||
|
return MsgType.CF_ENTITY_DYNAMIC_ARGUMENTS_REFRESH_MSG; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,104 @@ |
|||||
|
/** |
||||
|
* 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.geofencing; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.thingsboard.script.api.tbel.TbelCfArg; |
||||
|
import org.thingsboard.script.api.tbel.TbelCfTsGeofencingArg; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.kv.KvEntry; |
||||
|
import org.thingsboard.server.common.util.ProtoUtils; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntryType; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
@Data |
||||
|
@Slf4j |
||||
|
public class GeofencingArgumentEntry implements ArgumentEntry { |
||||
|
|
||||
|
private Map<EntityId, GeofencingZoneState> zoneStates; |
||||
|
|
||||
|
private boolean forceResetPrevious; |
||||
|
|
||||
|
public GeofencingArgumentEntry() { |
||||
|
} |
||||
|
|
||||
|
public GeofencingArgumentEntry(EntityId entityId, TransportProtos.AttributeValueProto entry) { |
||||
|
this.zoneStates = toZones(Map.of(entityId, ProtoUtils.fromProto(entry))); |
||||
|
} |
||||
|
|
||||
|
public GeofencingArgumentEntry(Map<EntityId, KvEntry> entityIdkvEntryMap) { |
||||
|
this.zoneStates = toZones(entityIdkvEntryMap); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public ArgumentEntryType getType() { |
||||
|
return ArgumentEntryType.GEOFENCING; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public Object getValue() { |
||||
|
return zoneStates; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean updateEntry(ArgumentEntry entry) { |
||||
|
if (!(entry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { |
||||
|
throw new IllegalArgumentException("Unsupported argument entry type for geofencing argument entry: " + entry.getType()); |
||||
|
} |
||||
|
boolean updated = false; |
||||
|
for (var zoneEntry : geofencingArgumentEntry.getZoneStates().entrySet()) { |
||||
|
if (updateZone(zoneEntry)) { |
||||
|
updated = true; |
||||
|
} |
||||
|
} |
||||
|
return updated; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean isEmpty() { |
||||
|
return zoneStates == null || zoneStates.isEmpty(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public TbelCfArg toTbelCfArg() { |
||||
|
return new TbelCfTsGeofencingArg(zoneStates); |
||||
|
} |
||||
|
|
||||
|
private Map<EntityId, GeofencingZoneState> toZones(Map<EntityId, KvEntry> entityIdKvEntryMap) { |
||||
|
return entityIdKvEntryMap.entrySet().stream() |
||||
|
.filter(entry -> entry.getValue().getJsonValue().isPresent()) |
||||
|
.collect(Collectors.toMap(Map.Entry::getKey, |
||||
|
entry -> new GeofencingZoneState(entry.getKey(), entry.getValue()))); |
||||
|
} |
||||
|
|
||||
|
private boolean updateZone(Map.Entry<EntityId, GeofencingZoneState> zoneEntry) { |
||||
|
EntityId zoneId = zoneEntry.getKey(); |
||||
|
GeofencingZoneState newZoneState = zoneEntry.getValue(); |
||||
|
|
||||
|
GeofencingZoneState existingZoneState = zoneStates.get(zoneId); |
||||
|
if (existingZoneState == null) { |
||||
|
zoneStates.put(zoneId, newZoneState); |
||||
|
return true; |
||||
|
} |
||||
|
return existingZoneState.update(newZoneState); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,207 @@ |
|||||
|
/** |
||||
|
* 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.geofencing; |
||||
|
|
||||
|
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 lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.common.util.geo.Coordinates; |
||||
|
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.relation.EntityRelation; |
||||
|
import org.thingsboard.server.service.cf.CalculatedFieldResult; |
||||
|
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.BaseCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE; |
||||
|
|
||||
|
@Data |
||||
|
@Slf4j |
||||
|
@EqualsAndHashCode(callSuper = true) |
||||
|
public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { |
||||
|
|
||||
|
private boolean dirty; |
||||
|
|
||||
|
public GeofencingCalculatedFieldState() { |
||||
|
super(new ArrayList<>(), new HashMap<>(), false, -1); |
||||
|
this.dirty = false; |
||||
|
} |
||||
|
|
||||
|
public GeofencingCalculatedFieldState(List<String> argNames) { |
||||
|
super(argNames); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public CalculatedFieldType getType() { |
||||
|
return CalculatedFieldType.GEOFENCING; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean updateState(CalculatedFieldCtx ctx, Map<String, ArgumentEntry> argumentValues) { |
||||
|
if (arguments == null) { |
||||
|
arguments = new HashMap<>(); |
||||
|
} |
||||
|
|
||||
|
boolean stateUpdated = false; |
||||
|
|
||||
|
for (var entry : argumentValues.entrySet()) { |
||||
|
String key = entry.getKey(); |
||||
|
ArgumentEntry newEntry = entry.getValue(); |
||||
|
|
||||
|
checkArgumentSize(key, newEntry, ctx); |
||||
|
|
||||
|
ArgumentEntry existingEntry = arguments.get(key); |
||||
|
boolean entryUpdated; |
||||
|
|
||||
|
if (existingEntry == null || newEntry.isForceResetPrevious()) { |
||||
|
entryUpdated = switch (key) { |
||||
|
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { |
||||
|
if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) { |
||||
|
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + |
||||
|
"Only SINGLE_VALUE type is allowed."); |
||||
|
} |
||||
|
arguments.put(key, singleValueArgumentEntry); |
||||
|
yield true; |
||||
|
} |
||||
|
default -> { |
||||
|
if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) { |
||||
|
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + |
||||
|
"Only GEOFENCING type is allowed."); |
||||
|
} |
||||
|
arguments.put(key, geofencingArgumentEntry); |
||||
|
yield true; |
||||
|
} |
||||
|
}; |
||||
|
} else { |
||||
|
entryUpdated = existingEntry.updateEntry(newEntry); |
||||
|
} |
||||
|
if (entryUpdated) { |
||||
|
stateUpdated = true; |
||||
|
} |
||||
|
} |
||||
|
return stateUpdated; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public ListenableFuture<CalculatedFieldResult> performCalculation(EntityId entityId, CalculatedFieldCtx ctx) { |
||||
|
double latitude = (double) arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY).getValue(); |
||||
|
double longitude = (double) arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY).getValue(); |
||||
|
Coordinates entityCoordinates = new Coordinates(latitude, longitude); |
||||
|
|
||||
|
var geofencingCfg = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
||||
|
Map<String, ZoneGroupConfiguration> zoneGroups = geofencingCfg.getZoneGroups(); |
||||
|
|
||||
|
ObjectNode resultNode = JacksonUtil.newObjectNode(); |
||||
|
List<ListenableFuture<Boolean>> relationFutures = new ArrayList<>(); |
||||
|
|
||||
|
getGeofencingArguments().forEach((argumentKey, argumentEntry) -> { |
||||
|
ZoneGroupConfiguration zoneGroupCfg = zoneGroups.get(argumentKey); |
||||
|
if (zoneGroupCfg == null) { |
||||
|
throw new RuntimeException("Zone group configuration is missing for the: " + entityId); |
||||
|
} |
||||
|
boolean createRelationsWithMatchedZones = zoneGroupCfg.isCreateRelationsWithMatchedZones(); |
||||
|
List<GeofencingEvalResult> zoneResults = new ArrayList<>(argumentEntry.getZoneStates().size()); |
||||
|
argumentEntry.getZoneStates().forEach((zoneId, zoneState) -> { |
||||
|
GeofencingEvalResult eval = zoneState.evaluate(entityCoordinates); |
||||
|
zoneResults.add(eval); |
||||
|
if (createRelationsWithMatchedZones) { |
||||
|
GeofencingTransitionEvent transitionEvent = eval.transition(); |
||||
|
if (transitionEvent == null) { |
||||
|
return; |
||||
|
} |
||||
|
EntityRelation relation = switch (zoneGroupCfg.getDirection()) { |
||||
|
case TO -> new EntityRelation(zoneId, entityId, zoneGroupCfg.getRelationType()); |
||||
|
case FROM -> new EntityRelation(entityId, zoneId, zoneGroupCfg.getRelationType()); |
||||
|
}; |
||||
|
ListenableFuture<Boolean> f = switch (transitionEvent) { |
||||
|
case ENTERED -> ctx.getRelationService().saveRelationAsync(ctx.getTenantId(), relation); |
||||
|
case LEFT -> ctx.getRelationService().deleteRelationAsync(ctx.getTenantId(), relation); |
||||
|
}; |
||||
|
relationFutures.add(f); |
||||
|
} |
||||
|
}); |
||||
|
updateResultNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), resultNode); |
||||
|
}); |
||||
|
|
||||
|
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), resultNode); |
||||
|
if (relationFutures.isEmpty()) { |
||||
|
return Futures.immediateFuture(result); |
||||
|
} |
||||
|
return Futures.whenAllComplete(relationFutures).call(() -> result, MoreExecutors.directExecutor()); |
||||
|
} |
||||
|
|
||||
|
private Map<String, GeofencingArgumentEntry> getGeofencingArguments() { |
||||
|
return arguments.entrySet() |
||||
|
.stream() |
||||
|
.filter(entry -> entry.getValue().getType().equals(ArgumentEntryType.GEOFENCING)) |
||||
|
.collect(Collectors.toMap(Map.Entry::getKey, entry -> (GeofencingArgumentEntry) entry.getValue())); |
||||
|
} |
||||
|
|
||||
|
private void updateResultNode(String argumentKey, List<GeofencingEvalResult> zoneResults, GeofencingReportStrategy geofencingReportStrategy, ObjectNode resultNode) { |
||||
|
GeofencingEvalResult aggregationResult = aggregateZoneGroup(zoneResults); |
||||
|
final String eventKey = argumentKey + "Event"; |
||||
|
final String statusKey = argumentKey + "Status"; |
||||
|
switch (geofencingReportStrategy) { |
||||
|
case REPORT_TRANSITION_EVENTS_ONLY -> addTransitionEventIfExists(resultNode, aggregationResult, eventKey); |
||||
|
case REPORT_PRESENCE_STATUS_ONLY -> resultNode.put(statusKey, aggregationResult.status().name()); |
||||
|
case REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS -> { |
||||
|
addTransitionEventIfExists(resultNode, aggregationResult, eventKey); |
||||
|
resultNode.put(statusKey, aggregationResult.status().name()); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private GeofencingEvalResult aggregateZoneGroup(List<GeofencingEvalResult> zoneResults) { |
||||
|
boolean nowInside = zoneResults.stream().anyMatch(r -> INSIDE.equals(r.status())); |
||||
|
boolean prevInside = zoneResults.stream() |
||||
|
.anyMatch(r -> GeofencingTransitionEvent.LEFT.equals(r.transition()) || r.transition() == null && r.status() == INSIDE); |
||||
|
GeofencingTransitionEvent transition = null; |
||||
|
if (!prevInside && nowInside) { |
||||
|
transition = GeofencingTransitionEvent.ENTERED; |
||||
|
} else if (prevInside && !nowInside) { |
||||
|
transition = GeofencingTransitionEvent.LEFT; |
||||
|
} |
||||
|
return new GeofencingEvalResult(transition, nowInside ? INSIDE : OUTSIDE); |
||||
|
} |
||||
|
|
||||
|
private void addTransitionEventIfExists(ObjectNode resultNode, GeofencingEvalResult aggregationResult, String eventKey) { |
||||
|
if (aggregationResult.transition() != null) { |
||||
|
resultNode.put(eventKey, aggregationResult.transition().name()); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
/** |
||||
|
* 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.geofencing; |
||||
|
|
||||
|
import jakarta.annotation.Nullable; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; |
||||
|
|
||||
|
public record GeofencingEvalResult(@Nullable GeofencingTransitionEvent transition, |
||||
|
GeofencingPresenceStatus status) { |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
/** |
||||
|
* 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.geofencing; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
import lombok.EqualsAndHashCode; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.common.util.geo.Coordinates; |
||||
|
import org.thingsboard.common.util.geo.PerimeterDefinition; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.KvEntry; |
||||
|
import org.thingsboard.server.common.util.ProtoUtils; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos.GeofencingZoneProto; |
||||
|
|
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE; |
||||
|
|
||||
|
@Data |
||||
|
public class GeofencingZoneState { |
||||
|
|
||||
|
private final EntityId zoneId; |
||||
|
|
||||
|
private long ts; |
||||
|
private Long version; |
||||
|
private PerimeterDefinition perimeterDefinition; |
||||
|
|
||||
|
@EqualsAndHashCode.Exclude |
||||
|
private GeofencingPresenceStatus lastPresence; |
||||
|
|
||||
|
public GeofencingZoneState(EntityId zoneId, KvEntry entry) { |
||||
|
this.zoneId = zoneId; |
||||
|
if (!(entry instanceof AttributeKvEntry attributeKvEntry)) { |
||||
|
throw new IllegalArgumentException("Unsupported KvEntry type for geofencing zone state: " + entry.getClass().getSimpleName()); |
||||
|
} |
||||
|
this.ts = attributeKvEntry.getLastUpdateTs(); |
||||
|
this.version = attributeKvEntry.getVersion(); |
||||
|
this.perimeterDefinition = JacksonUtil.fromString(entry.getJsonValue().orElseThrow(), PerimeterDefinition.class); |
||||
|
} |
||||
|
|
||||
|
public GeofencingZoneState(GeofencingZoneProto proto) { |
||||
|
this.zoneId = ProtoUtils.fromProto(proto.getZoneId()); |
||||
|
this.ts = proto.getTs(); |
||||
|
this.version = proto.getVersion(); |
||||
|
this.perimeterDefinition = JacksonUtil.fromString(proto.getPerimeterDefinition(), PerimeterDefinition.class); |
||||
|
if (proto.hasInside()) { |
||||
|
this.lastPresence = proto.getInside() ? INSIDE : OUTSIDE; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public boolean update(GeofencingZoneState newZoneState) { |
||||
|
if (newZoneState.getTs() <= this.ts) { |
||||
|
return false; |
||||
|
} |
||||
|
Long newVersion = newZoneState.getVersion(); |
||||
|
if (newVersion == null || this.version == null || newVersion > this.version) { |
||||
|
this.ts = newZoneState.getTs(); |
||||
|
this.version = newVersion; |
||||
|
this.perimeterDefinition = newZoneState.getPerimeterDefinition(); |
||||
|
this.lastPresence = null; |
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
public GeofencingEvalResult evaluate(Coordinates entityCoordinates) { |
||||
|
boolean nowInside = perimeterDefinition.checkMatches(entityCoordinates); |
||||
|
|
||||
|
GeofencingPresenceStatus status = nowInside ? INSIDE : OUTSIDE; |
||||
|
|
||||
|
// first evaluation
|
||||
|
if (this.lastPresence == null) { |
||||
|
this.lastPresence = status; |
||||
|
GeofencingTransitionEvent transition = null; |
||||
|
if (status == GeofencingPresenceStatus.INSIDE) { |
||||
|
transition = GeofencingTransitionEvent.ENTERED; |
||||
|
} |
||||
|
return new GeofencingEvalResult(transition, status); |
||||
|
} |
||||
|
// State changed
|
||||
|
if (this.lastPresence != status) { |
||||
|
this.lastPresence = status; |
||||
|
GeofencingTransitionEvent transition = (status == GeofencingPresenceStatus.INSIDE) ? |
||||
|
GeofencingTransitionEvent.ENTERED : GeofencingTransitionEvent.LEFT; |
||||
|
return new GeofencingEvalResult(transition, status); |
||||
|
} |
||||
|
// State unchanged
|
||||
|
return new GeofencingEvalResult(null, status); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,72 @@ |
|||||
|
/** |
||||
|
* 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.utils; |
||||
|
|
||||
|
import com.google.common.util.concurrent.Futures; |
||||
|
import com.google.common.util.concurrent.ListenableFuture; |
||||
|
import com.google.common.util.concurrent.MoreExecutors; |
||||
|
import org.apache.commons.lang3.math.NumberUtils; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.Argument; |
||||
|
import org.thingsboard.server.common.data.kv.BooleanDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.KvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.StringDataEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ScriptCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.SimpleCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.SingleValueArgumentEntry; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
public class CalculatedFieldArgumentUtils { |
||||
|
|
||||
|
public static ListenableFuture<ArgumentEntry> transformSingleValueArgument(ListenableFuture<Optional<? extends KvEntry>> kvEntryFuture) { |
||||
|
return Futures.transform(kvEntryFuture, kvEntry -> { |
||||
|
if (kvEntry.isPresent() && kvEntry.get().getValue() != null) { |
||||
|
return ArgumentEntry.createSingleValueArgument(kvEntry.get()); |
||||
|
} |
||||
|
return new SingleValueArgumentEntry(); |
||||
|
}, MoreExecutors.directExecutor()); |
||||
|
} |
||||
|
|
||||
|
public static KvEntry createDefaultKvEntry(Argument argument) { |
||||
|
String key = argument.getRefEntityKey().getKey(); |
||||
|
String defaultValue = argument.getDefaultValue(); |
||||
|
if (StringUtils.isBlank(defaultValue)) { |
||||
|
return new StringDataEntry(key, null); |
||||
|
} |
||||
|
if (NumberUtils.isParsable(defaultValue)) { |
||||
|
return new DoubleDataEntry(key, Double.parseDouble(defaultValue)); |
||||
|
} |
||||
|
if ("true".equalsIgnoreCase(defaultValue) || "false".equalsIgnoreCase(defaultValue)) { |
||||
|
return new BooleanDataEntry(key, Boolean.parseBoolean(defaultValue)); |
||||
|
} |
||||
|
return new StringDataEntry(key, defaultValue); |
||||
|
} |
||||
|
|
||||
|
public static CalculatedFieldState createStateByType(CalculatedFieldCtx ctx) { |
||||
|
return switch (ctx.getCfType()) { |
||||
|
case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames()); |
||||
|
case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames()); |
||||
|
case GEOFENCING -> new GeofencingCalculatedFieldState(ctx.getArgNames()); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,483 @@ |
|||||
|
/** |
||||
|
* 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.google.common.util.concurrent.Futures; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.ArgumentCaptor; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
import org.thingsboard.server.common.data.cf.CalculatedField; |
||||
|
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
||||
|
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.RelationQueryDynamicSourceConfiguration; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; |
||||
|
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.BaseAttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.DoubleDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.JsonDataEntry; |
||||
|
import org.thingsboard.server.common.data.relation.EntityRelation; |
||||
|
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
||||
|
import org.thingsboard.server.dao.relation.RelationService; |
||||
|
import org.thingsboard.server.dao.usagerecord.ApiLimitService; |
||||
|
import org.thingsboard.server.service.cf.CalculatedFieldResult; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
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.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.ArgumentMatchers.eq; |
||||
|
import static org.mockito.Mockito.times; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class GeofencingCalculatedFieldStateTest { |
||||
|
|
||||
|
private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("8f83eeca-b5cd-4955-9241-09d1393768c6")); |
||||
|
private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("688b529d-cfbe-4430-91c5-60b4f4e5d3cf")); |
||||
|
private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714")); |
||||
|
private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4")); |
||||
|
|
||||
|
private final SingleValueArgumentEntry latitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 10, new DoubleDataEntry("latitude", 50.4730), 145L); |
||||
|
private final SingleValueArgumentEntry longitudeArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis() - 6, new DoubleDataEntry("longitude", 30.5050), 165L); |
||||
|
|
||||
|
private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"); |
||||
|
private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, System.currentTimeMillis(), 0L); |
||||
|
private final GeofencingArgumentEntry geofencingAllowedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry)); |
||||
|
|
||||
|
private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"); |
||||
|
private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, System.currentTimeMillis(), 0L); |
||||
|
private final GeofencingArgumentEntry geofencingRestrictedZoneArgEntry = new GeofencingArgumentEntry(Map.of(ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
|
||||
|
|
||||
|
private GeofencingCalculatedFieldState state; |
||||
|
private CalculatedFieldCtx ctx; |
||||
|
|
||||
|
@Mock |
||||
|
private ApiLimitService apiLimitService; |
||||
|
@Mock |
||||
|
private RelationService relationService; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
when(apiLimitService.getLimit(any(), any())).thenReturn(1000L); |
||||
|
ctx = new CalculatedFieldCtx(getCalculatedField(), null, apiLimitService, relationService); |
||||
|
ctx.init(); |
||||
|
state = new GeofencingCalculatedFieldState(ctx.getArgNames()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testType() { |
||||
|
assertThat(state.getType()).isEqualTo(CalculatedFieldType.GEOFENCING); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateState() { |
||||
|
state.arguments = new HashMap<>(Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry |
||||
|
)); |
||||
|
|
||||
|
Map<String, ArgumentEntry> newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry); |
||||
|
boolean stateUpdated = state.updateState(ctx, newArgs); |
||||
|
|
||||
|
assertThat(stateUpdated).isTrue(); |
||||
|
assertThat(state.getArguments()).containsExactlyInAnyOrderEntriesOf( |
||||
|
Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, |
||||
|
"allowedZones", geofencingAllowedZoneArgEntry |
||||
|
) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWithInvalidArgumentTypeForLatitudeArgument() { |
||||
|
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for latitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed."); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWithInvalidArgumentTypeForLongitudeArgument() { |
||||
|
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for longitude argument: GEOFENCING. Only SINGLE_VALUE type is allowed."); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWithInvalidArgumentTypeForGeofencingArgument() { |
||||
|
assertThatThrownBy(() -> state.updateState(ctx, Map.of("someArgumentName", latitudeArgEntry))) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for someArgumentName argument: SINGLE_VALUE. Only GEOFENCING type is allowed."); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWhenUpdateExistingSingleValueArgumentEntry() { |
||||
|
state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry)); |
||||
|
|
||||
|
SingleValueArgumentEntry newArgEntry = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 190L); |
||||
|
Map<String, ArgumentEntry> newArgs = Map.of("latitude", newArgEntry); |
||||
|
boolean stateUpdated = state.updateState(ctx, newArgs); |
||||
|
|
||||
|
assertThat(stateUpdated).isTrue(); |
||||
|
assertThat(state.getArguments()).isEqualTo(newArgs); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithTheSameValue() { |
||||
|
state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry)); |
||||
|
|
||||
|
Map<String, ArgumentEntry> newArgs = Map.of("allowedZones", geofencingAllowedZoneArgEntry); |
||||
|
|
||||
|
boolean stateUpdated = state.updateState(ctx, newArgs); |
||||
|
|
||||
|
assertThat(stateUpdated).isFalse(); |
||||
|
assertThat(state.getArguments()).isEqualTo(newArgs); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWhenUpdateExistingSingleValueArgumentEntryWithValueOfAnotherType() { |
||||
|
state.arguments = new HashMap<>(Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry)); |
||||
|
|
||||
|
assertThatThrownBy(() -> state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, geofencingAllowedZoneArgEntry))) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for single value argument entry: GEOFENCING"); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
@Test |
||||
|
void testUpdateStateWhenUpdateExistingGeofencingValueArgumentEntryWithValueOfAnotherType() { |
||||
|
state.arguments = new HashMap<>(Map.of("allowedZones", geofencingAllowedZoneArgEntry)); |
||||
|
|
||||
|
assertThatThrownBy(() -> state.updateState(ctx, Map.of("allowedZones", latitudeArgEntry))) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testIsReadyWhenNotAllArgPresent() { |
||||
|
assertThat(state.isReady()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testIsReadyWhenAllArgPresent() { |
||||
|
state.arguments = new HashMap<>(Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, |
||||
|
"allowedZones", geofencingAllowedZoneArgEntry, |
||||
|
"restrictedZones", geofencingRestrictedZoneArgEntry |
||||
|
)); |
||||
|
assertThat(state.isReady()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testIsReadyWhenEmptyEntryPresents() { |
||||
|
state.arguments = new HashMap<>(Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, |
||||
|
"allowedZones", geofencingAllowedZoneArgEntry, |
||||
|
"restrictedZones", geofencingRestrictedZoneArgEntry |
||||
|
)); |
||||
|
|
||||
|
state.getArguments().put("noParkingZones", new GeofencingArgumentEntry()); |
||||
|
|
||||
|
assertThat(state.isReady()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testPerformCalculation() throws ExecutionException, InterruptedException { |
||||
|
state.arguments = new HashMap<>(Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, |
||||
|
"allowedZones", geofencingAllowedZoneArgEntry, |
||||
|
"restrictedZones", geofencingRestrictedZoneArgEntry |
||||
|
)); |
||||
|
|
||||
|
Output output = ctx.getOutput(); |
||||
|
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
||||
|
|
||||
|
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); |
||||
|
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); |
||||
|
|
||||
|
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); |
||||
|
|
||||
|
assertThat(result).isNotNull(); |
||||
|
assertThat(result.getType()).isEqualTo(output.getType()); |
||||
|
assertThat(result.getScope()).isEqualTo(output.getScope()); |
||||
|
assertThat(result.getResult()).isEqualTo( |
||||
|
JacksonUtil.newObjectNode() |
||||
|
.put("allowedZonesEvent", "ENTERED") |
||||
|
.put("allowedZonesStatus", "INSIDE") |
||||
|
.put("restrictedZonesStatus", "OUTSIDE") |
||||
|
); |
||||
|
|
||||
|
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); |
||||
|
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); |
||||
|
|
||||
|
// move the device to new coordinates → leaves allowed, enters restricted
|
||||
|
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude)); |
||||
|
|
||||
|
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); |
||||
|
|
||||
|
assertThat(result2).isNotNull(); |
||||
|
assertThat(result2.getType()).isEqualTo(output.getType()); |
||||
|
assertThat(result2.getScope()).isEqualTo(output.getScope()); |
||||
|
assertThat(result2.getResult()).isEqualTo( |
||||
|
JacksonUtil.newObjectNode() |
||||
|
.put("allowedZonesEvent", "LEFT") |
||||
|
.put("allowedZonesStatus", "OUTSIDE") |
||||
|
.put("restrictedZonesEvent", "ENTERED") |
||||
|
.put("restrictedZonesStatus", "INSIDE") |
||||
|
); |
||||
|
|
||||
|
// Check relations are created and deleted correctly for both iterations.
|
||||
|
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class); |
||||
|
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture()); |
||||
|
List<EntityRelation> saveValues = saveCaptor.getAllValues(); |
||||
|
assertThat(saveValues).hasSize(2); |
||||
|
|
||||
|
EntityRelation relationFromFirstIteration = saveValues.get(0); |
||||
|
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID); |
||||
|
assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone"); |
||||
|
|
||||
|
EntityRelation relationFromSecondIteration = saveValues.get(1); |
||||
|
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID); |
||||
|
assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); |
||||
|
|
||||
|
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); |
||||
|
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); |
||||
|
EntityRelation leftRelation = deleteCaptor.getValue(); |
||||
|
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); |
||||
|
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testPerformCalculationWithOnlyTransitionEventsReportingStrategy() throws ExecutionException, InterruptedException { |
||||
|
state.arguments = new HashMap<>(Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, |
||||
|
"allowedZones", geofencingAllowedZoneArgEntry, |
||||
|
"restrictedZones", geofencingRestrictedZoneArgEntry |
||||
|
)); |
||||
|
|
||||
|
Output output = ctx.getOutput(); |
||||
|
|
||||
|
var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_ONLY); |
||||
|
|
||||
|
ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig)); |
||||
|
ctx.init(); |
||||
|
|
||||
|
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
||||
|
|
||||
|
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); |
||||
|
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); |
||||
|
|
||||
|
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); |
||||
|
|
||||
|
assertThat(result).isNotNull(); |
||||
|
assertThat(result.getType()).isEqualTo(output.getType()); |
||||
|
assertThat(result.getScope()).isEqualTo(output.getScope()); |
||||
|
assertThat(result.getResult()).isEqualTo( |
||||
|
JacksonUtil.newObjectNode().put("allowedZonesEvent", "ENTERED") |
||||
|
); |
||||
|
|
||||
|
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); |
||||
|
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); |
||||
|
|
||||
|
// move the device to new coordinates → leaves allowed, enters restricted
|
||||
|
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude)); |
||||
|
|
||||
|
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); |
||||
|
|
||||
|
assertThat(result2).isNotNull(); |
||||
|
assertThat(result2.getType()).isEqualTo(output.getType()); |
||||
|
assertThat(result2.getScope()).isEqualTo(output.getScope()); |
||||
|
assertThat(result2.getResult()).isEqualTo( |
||||
|
JacksonUtil.newObjectNode() |
||||
|
.put("allowedZonesEvent", "LEFT") |
||||
|
.put("restrictedZonesEvent", "ENTERED") |
||||
|
); |
||||
|
|
||||
|
// Check relations are created and deleted correctly for both iterations.
|
||||
|
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class); |
||||
|
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture()); |
||||
|
List<EntityRelation> saveValues = saveCaptor.getAllValues(); |
||||
|
assertThat(saveValues).hasSize(2); |
||||
|
|
||||
|
EntityRelation relationFromFirstIteration = saveValues.get(0); |
||||
|
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID); |
||||
|
assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone"); |
||||
|
|
||||
|
EntityRelation relationFromSecondIteration = saveValues.get(1); |
||||
|
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID); |
||||
|
assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); |
||||
|
|
||||
|
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); |
||||
|
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); |
||||
|
EntityRelation leftRelation = deleteCaptor.getValue(); |
||||
|
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); |
||||
|
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testPerformCalculationWithOnlyPresenceStatusReportingStrategy() throws ExecutionException, InterruptedException { |
||||
|
state.arguments = new HashMap<>(Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, latitudeArgEntry, |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, longitudeArgEntry, |
||||
|
"allowedZones", geofencingAllowedZoneArgEntry, |
||||
|
"restrictedZones", geofencingRestrictedZoneArgEntry |
||||
|
)); |
||||
|
|
||||
|
Output output = ctx.getOutput(); |
||||
|
|
||||
|
var calculatedFieldConfig = getCalculatedFieldConfig(GeofencingReportStrategy.REPORT_PRESENCE_STATUS_ONLY); |
||||
|
|
||||
|
ctx.setCalculatedField(getCalculatedField(calculatedFieldConfig)); |
||||
|
ctx.init(); |
||||
|
|
||||
|
var configuration = (GeofencingCalculatedFieldConfiguration) ctx.getCalculatedField().getConfiguration(); |
||||
|
|
||||
|
when(relationService.saveRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); |
||||
|
when(relationService.deleteRelationAsync(any(), any())).thenReturn(Futures.immediateFuture(true)); |
||||
|
|
||||
|
CalculatedFieldResult result = state.performCalculation(ctx.getEntityId(), ctx).get(); |
||||
|
|
||||
|
assertThat(result).isNotNull(); |
||||
|
assertThat(result.getType()).isEqualTo(output.getType()); |
||||
|
assertThat(result.getScope()).isEqualTo(output.getScope()); |
||||
|
assertThat(result.getResult()).isEqualTo( |
||||
|
JacksonUtil.newObjectNode() |
||||
|
.put("allowedZonesStatus", "INSIDE") |
||||
|
.put("restrictedZonesStatus", "OUTSIDE") |
||||
|
); |
||||
|
|
||||
|
SingleValueArgumentEntry newLatitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("latitude", 50.4760), 146L); |
||||
|
SingleValueArgumentEntry newLongitude = new SingleValueArgumentEntry(System.currentTimeMillis(), new DoubleDataEntry("longitude", 30.5110), 166L); |
||||
|
|
||||
|
// move the device to new coordinates → leaves allowed, enters restricted
|
||||
|
state.updateState(ctx, Map.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, newLatitude, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, newLongitude)); |
||||
|
|
||||
|
CalculatedFieldResult result2 = state.performCalculation(ctx.getEntityId(), ctx).get(); |
||||
|
|
||||
|
assertThat(result2).isNotNull(); |
||||
|
assertThat(result2.getType()).isEqualTo(output.getType()); |
||||
|
assertThat(result2.getScope()).isEqualTo(output.getScope()); |
||||
|
assertThat(result2.getResult()).isEqualTo( |
||||
|
JacksonUtil.newObjectNode() |
||||
|
.put("allowedZonesStatus", "OUTSIDE") |
||||
|
.put("restrictedZonesStatus", "INSIDE") |
||||
|
); |
||||
|
|
||||
|
// Check relations are created and deleted correctly for both iterations.
|
||||
|
ArgumentCaptor<EntityRelation> saveCaptor = ArgumentCaptor.forClass(EntityRelation.class); |
||||
|
verify(relationService, times(2)).saveRelationAsync(eq(ctx.getTenantId()), saveCaptor.capture()); |
||||
|
List<EntityRelation> saveValues = saveCaptor.getAllValues(); |
||||
|
assertThat(saveValues).hasSize(2); |
||||
|
|
||||
|
EntityRelation relationFromFirstIteration = saveValues.get(0); |
||||
|
assertThat(relationFromFirstIteration.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
assertThat(relationFromFirstIteration.getFrom()).isEqualTo(ZONE_1_ID); |
||||
|
assertThat(relationFromFirstIteration.getType()).isEqualTo("CurrentZone"); |
||||
|
|
||||
|
EntityRelation relationFromSecondIteration = saveValues.get(1); |
||||
|
assertThat(relationFromSecondIteration.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
assertThat(relationFromSecondIteration.getFrom()).isEqualTo(ZONE_2_ID); |
||||
|
assertThat(relationFromSecondIteration.getType()).isEqualTo("CurrentZone"); |
||||
|
|
||||
|
ArgumentCaptor<EntityRelation> deleteCaptor = ArgumentCaptor.forClass(EntityRelation.class); |
||||
|
verify(relationService).deleteRelationAsync(eq(ctx.getTenantId()), deleteCaptor.capture()); |
||||
|
EntityRelation leftRelation = deleteCaptor.getValue(); |
||||
|
assertThat(leftRelation.getFrom()).isEqualTo(ZONE_1_ID); |
||||
|
assertThat(leftRelation.getTo()).isEqualTo(ctx.getEntityId()); |
||||
|
} |
||||
|
|
||||
|
private CalculatedField getCalculatedField() { |
||||
|
return getCalculatedField(getCalculatedFieldConfig(REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS)); |
||||
|
} |
||||
|
|
||||
|
private CalculatedField getCalculatedField(CalculatedFieldConfiguration configuration) { |
||||
|
CalculatedField calculatedField = new CalculatedField(); |
||||
|
calculatedField.setTenantId(TENANT_ID); |
||||
|
calculatedField.setEntityId(DEVICE_ID); |
||||
|
calculatedField.setType(CalculatedFieldType.GEOFENCING); |
||||
|
calculatedField.setName("Test Geofencing Calculated Field"); |
||||
|
calculatedField.setConfigurationVersion(1); |
||||
|
calculatedField.setConfiguration(configuration); |
||||
|
calculatedField.setVersion(1L); |
||||
|
return calculatedField; |
||||
|
} |
||||
|
|
||||
|
private CalculatedFieldConfiguration getCalculatedFieldConfig(GeofencingReportStrategy reportStrategy) { |
||||
|
var config = new GeofencingCalculatedFieldConfiguration(); |
||||
|
|
||||
|
EntityCoordinates entityCoordinates = new EntityCoordinates("latitude", "longitude"); |
||||
|
config.setEntityCoordinates(entityCoordinates); |
||||
|
|
||||
|
ZoneGroupConfiguration allowedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); |
||||
|
var allowedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); |
||||
|
allowedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO); |
||||
|
allowedZoneDynamicSourceConfiguration.setRelationType("AllowedZone"); |
||||
|
allowedZoneDynamicSourceConfiguration.setMaxLevel(1); |
||||
|
allowedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); |
||||
|
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); |
||||
|
allowedZonesGroup.setRelationType("CurrentZone"); |
||||
|
allowedZonesGroup.setDirection(EntitySearchDirection.TO); |
||||
|
|
||||
|
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); |
||||
|
var restrictedZoneDynamicSourceConfiguration = new RelationQueryDynamicSourceConfiguration(); |
||||
|
restrictedZoneDynamicSourceConfiguration.setDirection(EntitySearchDirection.TO); |
||||
|
restrictedZoneDynamicSourceConfiguration.setRelationType("RestrictedZone"); |
||||
|
restrictedZoneDynamicSourceConfiguration.setMaxLevel(1); |
||||
|
restrictedZoneDynamicSourceConfiguration.setFetchLastLevelOnly(true); |
||||
|
restrictedZonesGroup.setRefDynamicSourceConfiguration(restrictedZoneDynamicSourceConfiguration); |
||||
|
restrictedZonesGroup.setRelationType("CurrentZone"); |
||||
|
restrictedZonesGroup.setDirection(EntitySearchDirection.TO); |
||||
|
|
||||
|
config.setZoneGroups(Map.of("allowedZones", allowedZonesGroup, "restrictedZones", restrictedZonesGroup)); |
||||
|
|
||||
|
Output output = new Output(); |
||||
|
output.setType(OutputType.TIME_SERIES); |
||||
|
config.setOutput(output); |
||||
|
return config; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,183 @@ |
|||||
|
/** |
||||
|
* 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 io.hypersistence.utils.hibernate.type.json.internal.JacksonUtil; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.thingsboard.common.util.geo.PerimeterDefinition; |
||||
|
import org.thingsboard.server.common.data.id.AssetId; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.JsonDataEntry; |
||||
|
import org.thingsboard.server.common.data.kv.StringDataEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; |
||||
|
|
||||
|
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 GeofencingValueArgumentEntryTest { |
||||
|
|
||||
|
private final AssetId ZONE_1_ID = new AssetId(UUID.fromString("c0e3031c-7df1-45e4-9590-cfd621a4d714")); |
||||
|
private final AssetId ZONE_2_ID = new AssetId(UUID.fromString("e7da6200-2096-4038-a343-ade9ea4fa3e4")); |
||||
|
|
||||
|
private final JsonDataEntry allowedZoneDataEntry = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"); |
||||
|
private final BaseAttributeKvEntry allowedZoneAttributeKvEntry = new BaseAttributeKvEntry(allowedZoneDataEntry, 363L, 155L); |
||||
|
|
||||
|
private final JsonDataEntry restrictedZoneDataEntry = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"); |
||||
|
private final BaseAttributeKvEntry restrictedZoneAttributeKvEntry = new BaseAttributeKvEntry(restrictedZoneDataEntry, 363L, 155L); |
||||
|
|
||||
|
private GeofencingArgumentEntry entry; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
entry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testArgumentEntryType() { |
||||
|
assertThat(entry.getType()).isEqualTo(ArgumentEntryType.GEOFENCING); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWhenSingleEntryPassed() { |
||||
|
assertThatThrownBy(() -> entry.updateEntry(new SingleValueArgumentEntry())) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for geofencing argument entry: SINGLE_VALUE"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWhenRollingEntryPassed() { |
||||
|
assertThatThrownBy(() -> entry.updateEntry(new TsRollingArgumentEntry(5, 30000L))) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Unsupported argument entry type for geofencing argument entry: TS_ROLLING"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWithTheSameTs() { |
||||
|
BaseAttributeKvEntry differentValueSameTs = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 363L, 156L); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueSameTs, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
assertThat(entry.updateEntry(updated)).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void testUpdateEntryWhenNewVersionIsNull() { |
||||
|
BaseAttributeKvEntry differentValueNewVersionIsNull = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, null); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsNull, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
|
||||
|
assertThat(entry.updateEntry(updated)).isTrue(); |
||||
|
assertThat(entry.getValue()).isInstanceOf(Map.class); |
||||
|
|
||||
|
Map<EntityId, GeofencingZoneState> value = (Map<EntityId, GeofencingZoneState>) entry.getValue(); |
||||
|
assertThat(value).hasSize(2); |
||||
|
assertThat(value.get(ZONE_1_ID).getVersion()).isNull(); |
||||
|
assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L); |
||||
|
assertThat(value.get(ZONE_1_ID).getPerimeterDefinition()) |
||||
|
.isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsNull.getJsonValue().get(), PerimeterDefinition.class)); |
||||
|
|
||||
|
assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L); |
||||
|
assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L); |
||||
|
assertThat(value.get(ZONE_2_ID).getPerimeterDefinition()) |
||||
|
.isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void testUpdateEntryWhenNewVersionIsGreaterThanCurrent() { |
||||
|
BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
|
||||
|
assertThat(entry.updateEntry(updated)).isTrue(); |
||||
|
assertThat(entry.getValue()).isInstanceOf(Map.class); |
||||
|
|
||||
|
Map<EntityId, GeofencingZoneState> value = (Map<EntityId, GeofencingZoneState>) entry.getValue(); |
||||
|
assertThat(value).hasSize(2); |
||||
|
assertThat(value.get(ZONE_1_ID).getVersion()).isEqualTo(156L); |
||||
|
assertThat(value.get(ZONE_1_ID).getTs()).isEqualTo(364L); |
||||
|
assertThat(value.get(ZONE_1_ID).getPerimeterDefinition()) |
||||
|
.isEqualTo(JacksonUtil.fromString(differentValueNewVersionIsSet.getJsonValue().get(), PerimeterDefinition.class)); |
||||
|
|
||||
|
assertThat(value.get(ZONE_2_ID).getVersion()).isEqualTo(155L); |
||||
|
assertThat(value.get(ZONE_2_ID).getTs()).isEqualTo(363L); |
||||
|
assertThat(value.get(ZONE_2_ID).getPerimeterDefinition()) |
||||
|
.isEqualTo(JacksonUtil.fromString(restrictedZoneAttributeKvEntry.getJsonValue().get(), PerimeterDefinition.class)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWhenNewVersionIsLessThanCurrent() { |
||||
|
BaseAttributeKvEntry differentValueNewVersionIsSet = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 154L); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, differentValueNewVersionIsSet, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
|
||||
|
assertThat(entry.updateEntry(updated)).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWhenNewTsAndVersionIsGreaterThenCurrentAndValueWasNotChanged() { |
||||
|
BaseAttributeKvEntry newTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 364L, 156L); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, newTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
|
||||
|
assertThat(entry.updateEntry(updated)).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWithOldTs() { |
||||
|
BaseAttributeKvEntry oldTsAndTheSameValue = new BaseAttributeKvEntry(allowedZoneDataEntry, 362L, 156L); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, oldTsAndTheSameValue, ZONE_2_ID, restrictedZoneAttributeKvEntry)); |
||||
|
|
||||
|
assertThat(entry.updateEntry(updated)).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testUpdateEntryWithNewZone() { |
||||
|
final AssetId NEW_ZONE_ID = new AssetId(UUID.fromString("a3eacf1a-6af3-4e9f-87c4-502bb25c7dc3")); |
||||
|
BaseAttributeKvEntry newZone = new BaseAttributeKvEntry(new JsonDataEntry("zone", "[[50.472001, 30.504001], [50.472001, 30.506001], [50.474001, 30.506001], [50.474001, 30.504001]]"), 364L, 156L); |
||||
|
var updated = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, allowedZoneAttributeKvEntry, ZONE_2_ID, restrictedZoneAttributeKvEntry, NEW_ZONE_ID, newZone)); |
||||
|
assertThat(entry.updateEntry(updated)).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testIsEmpty() { |
||||
|
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(); |
||||
|
assertThat(geofencingArgumentEntry.isEmpty()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testIsEmptyWithEmptyMap() { |
||||
|
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(Map.of()); |
||||
|
assertThat(geofencingArgumentEntry.isEmpty()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testInvalidKvEntryDataTypeForZoneResultInEmptyArgument() { |
||||
|
BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new StringDataEntry("zone", "someString"), 363L, 155L); |
||||
|
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry)); |
||||
|
assertThat(geofencingArgumentEntry.isEmpty()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testNotParsableToPerimeterJsonKvEntryResultInExceptionTrowed() { |
||||
|
BaseAttributeKvEntry invalidZoneEntry = new BaseAttributeKvEntry(new JsonDataEntry("zone", "\"{}\""), 363L, 155L); |
||||
|
assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry))) |
||||
|
.isExactlyInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("The given string value cannot be transformed to Json object: \"{}\""); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,169 @@ |
|||||
|
/** |
||||
|
* 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.common.util.geo.Coordinates; |
||||
|
import org.thingsboard.server.common.data.id.AssetId; |
||||
|
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.JsonDataEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingEvalResult; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; |
||||
|
|
||||
|
import java.util.UUID; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.INSIDE; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus.OUTSIDE; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.ENTERED; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingTransitionEvent.LEFT; |
||||
|
|
||||
|
public class GeofencingZoneStateTest { |
||||
|
|
||||
|
private final AssetId ZONE_ID = new AssetId(UUID.fromString("628730fd-d625-417f-9c6d-ae9fe4addbdb")); |
||||
|
|
||||
|
private GeofencingZoneState state; |
||||
|
|
||||
|
@BeforeEach |
||||
|
void setUp() { |
||||
|
String POLYGON = "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"; |
||||
|
state = new GeofencingZoneState(ZONE_ID, new BaseAttributeKvEntry(new JsonDataEntry("zone", POLYGON), 100L, 1L)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void evaluate_initialInside_thenInsideAgain() { |
||||
|
var inside = new Coordinates(50.4730, 30.5050); |
||||
|
// first evaluation: no prior state -> ENTERED
|
||||
|
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); |
||||
|
// same position again -> INSIDE (steady state)
|
||||
|
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void evaluate_initialOutside_thenOutsideAgain() { |
||||
|
var outside = new Coordinates(50.4760, 30.5110); |
||||
|
// first evaluation: no prior state -> OUTSIDE
|
||||
|
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); |
||||
|
// same position again -> OUTSIDE (steady state)
|
||||
|
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void evaluate_inside_thenLeave() { |
||||
|
var inside = new Coordinates(50.4730, 30.5050); |
||||
|
var outside = new Coordinates(50.4760, 30.5110); |
||||
|
// enter
|
||||
|
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); |
||||
|
// leave -> LEFT
|
||||
|
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(LEFT, OUTSIDE)); |
||||
|
// still outside -> OUTSIDE
|
||||
|
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void evaluate_outside_thenEnter() { |
||||
|
var outside = new Coordinates(50.4760, 30.5110); |
||||
|
var inside = new Coordinates(50.4730, 30.5050); |
||||
|
// start outside
|
||||
|
assertThat(state.evaluate(outside)).isEqualTo(new GeofencingEvalResult(null, OUTSIDE)); |
||||
|
// cross boundary -> ENTERED
|
||||
|
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(ENTERED, INSIDE)); |
||||
|
// remain inside -> INSIDE
|
||||
|
assertThat(state.evaluate(inside)).isEqualTo(new GeofencingEvalResult(null, INSIDE)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void update_withNewerVersion_updatesState_andResetsPresence() { |
||||
|
// arrange: establish a prior presence to ensure it’s reset on update
|
||||
|
var inside = new Coordinates(50.4730, 30.5050); |
||||
|
assertThat(state.evaluate(inside)).isNotNull(); // sets lastPresence internally
|
||||
|
|
||||
|
String NEW_POLYGON = "[[50.470000, 30.502000], [50.470000, 30.503000], [50.471000, 30.503000], [50.471000, 30.502000]]"; |
||||
|
GeofencingZoneState newer = new GeofencingZoneState( |
||||
|
ZONE_ID, |
||||
|
new BaseAttributeKvEntry(new JsonDataEntry("zone", NEW_POLYGON), 200L, 2L) |
||||
|
); |
||||
|
|
||||
|
// act
|
||||
|
boolean changed = state.update(newer); |
||||
|
|
||||
|
// assert
|
||||
|
assertThat(changed).isTrue(); |
||||
|
assertThat(state.getTs()).isEqualTo(200L); |
||||
|
assertThat(state.getVersion()).isEqualTo(2L); |
||||
|
assertThat(state.getPerimeterDefinition()).isNotNull(); |
||||
|
assertThat(state.getLastPresence()).isNull(); // must be reset on successful update
|
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void update_withEqualVersion_doesNothing() { |
||||
|
// arrange: same version (1L) but different ts/polygon should still be ignored
|
||||
|
String SOME_POLYGON = "[[50.472500, 30.504500], [50.472500, 30.505500], [50.473500, 30.505500], [50.473500, 30.504500]]"; |
||||
|
GeofencingZoneState sameVersion = new GeofencingZoneState( |
||||
|
ZONE_ID, |
||||
|
new BaseAttributeKvEntry(new JsonDataEntry("zone", SOME_POLYGON), 300L, 1L) |
||||
|
); |
||||
|
|
||||
|
// act
|
||||
|
boolean changed = state.update(sameVersion); |
||||
|
|
||||
|
// assert: nothing changes
|
||||
|
assertThat(changed).isFalse(); |
||||
|
assertThat(state.getTs()).isEqualTo(100L); |
||||
|
assertThat(state.getVersion()).isEqualTo(1L); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void update_withNullNewVersion_alwaysApplies_andCopiesNull() { |
||||
|
// arrange: the implementation updates if newVersion == null
|
||||
|
String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]"; |
||||
|
GeofencingZoneState nullVersion = new GeofencingZoneState( |
||||
|
ZONE_ID, |
||||
|
new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, null) |
||||
|
); |
||||
|
|
||||
|
// act
|
||||
|
boolean changed = state.update(nullVersion); |
||||
|
|
||||
|
// assert: applied and version copied as null
|
||||
|
assertThat(changed).isTrue(); |
||||
|
assertThat(state.getTs()).isEqualTo(400L); |
||||
|
assertThat(state.getVersion()).isNull(); |
||||
|
assertThat(state.getLastPresence()).isNull(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void update_withNewVersionWhenExistingIsNull_alwaysApplies_andCopiesNew() { |
||||
|
// arrange: the implementation updates if newVersion == null
|
||||
|
String OTHER_POLYGON = "[[50.471000, 30.506000], [50.471000, 30.507000], [50.472000, 30.507000], [50.472000, 30.506000]]"; |
||||
|
GeofencingZoneState newVersion = new GeofencingZoneState( |
||||
|
ZONE_ID, |
||||
|
new BaseAttributeKvEntry(new JsonDataEntry("zone", OTHER_POLYGON), 400L, 2L) |
||||
|
); |
||||
|
state.setVersion(null); |
||||
|
|
||||
|
// act
|
||||
|
boolean changed = state.update(newVersion); |
||||
|
|
||||
|
// assert: applied and version copied as null
|
||||
|
assertThat(changed).isTrue(); |
||||
|
assertThat(state.getTs()).isEqualTo(400L); |
||||
|
assertThat(state.getVersion()).isEqualTo(2); |
||||
|
assertThat(state.getLastPresence()).isNull(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,109 @@ |
|||||
|
/** |
||||
|
* 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.utils; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingPresenceStatus; |
||||
|
import org.thingsboard.server.common.data.id.AssetId; |
||||
|
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
||||
|
import org.thingsboard.server.common.data.id.DeviceId; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.id.TenantId; |
||||
|
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
||||
|
import org.thingsboard.server.common.data.kv.JsonDataEntry; |
||||
|
import org.thingsboard.server.gen.transport.TransportProtos.CalculatedFieldStateProto; |
||||
|
import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingArgumentEntry; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; |
||||
|
import org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingZoneState; |
||||
|
|
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.UUID; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.mockito.BDDMockito.given; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
class CalculatedFieldUtilsTest { |
||||
|
|
||||
|
private static final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("0a69e1e2-fcbc-4234-a4cd-3844bf54035c")); |
||||
|
private static final CalculatedFieldId CF_ID = CalculatedFieldId.fromString("ec0e91b9-6f27-4e93-946a-5fbc2707d8bc"); |
||||
|
private static final DeviceId DEVICE_ID = DeviceId.fromString("1e03bd38-2010-4739-9362-160c288e36c4"); |
||||
|
|
||||
|
@Test |
||||
|
void toProtoAndFromProto_shouldMapGeofencingArgumentsAndZones() { |
||||
|
// given
|
||||
|
CalculatedFieldEntityCtxId stateId = mock(CalculatedFieldEntityCtxId.class); |
||||
|
given(stateId.tenantId()).willReturn(TENANT_ID); |
||||
|
given(stateId.cfId()).willReturn(CF_ID); |
||||
|
given(stateId.entityId()).willReturn(DEVICE_ID); |
||||
|
|
||||
|
// Build a geofencing argument with two zones (one with inside=true, one with inside=null)
|
||||
|
GeofencingArgumentEntry geofencingArgumentEntry = new GeofencingArgumentEntry(); |
||||
|
Map<EntityId, GeofencingZoneState> zoneStates = new LinkedHashMap<>(); |
||||
|
|
||||
|
UUID zoneId1 = UUID.fromString("624a8fff-71a2-4847-a100-ff1cf52dbe71"); |
||||
|
UUID zoneId2 = UUID.fromString("e2adf6ce-9478-40b1-b0e9-4a6860cc46bb"); |
||||
|
|
||||
|
AssetId z1 = new AssetId(zoneId1); |
||||
|
AssetId z2 = new AssetId(zoneId2); |
||||
|
|
||||
|
JsonDataEntry zone1 = new JsonDataEntry("zone", "[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"); |
||||
|
JsonDataEntry zone2 = new JsonDataEntry("zone", "[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"); |
||||
|
|
||||
|
BaseAttributeKvEntry zone1PerimeterAttribute = new BaseAttributeKvEntry(zone1, System.currentTimeMillis(), 0L); |
||||
|
BaseAttributeKvEntry zone2PerimeterAttribute = new BaseAttributeKvEntry(zone2, System.currentTimeMillis(), 0L); |
||||
|
|
||||
|
GeofencingZoneState s1 = new GeofencingZoneState(z1, zone1PerimeterAttribute); |
||||
|
s1.setLastPresence(GeofencingPresenceStatus.INSIDE); |
||||
|
GeofencingZoneState s2 = new GeofencingZoneState(z2, zone2PerimeterAttribute); |
||||
|
|
||||
|
zoneStates.put(z1, s1); |
||||
|
zoneStates.put(z2, s2); |
||||
|
geofencingArgumentEntry.setZoneStates(zoneStates); |
||||
|
|
||||
|
// Create cf state with the geofencing argument and add it to the state map
|
||||
|
CalculatedFieldState state = new GeofencingCalculatedFieldState(List.of("geofencingArgumentTest")); |
||||
|
state.updateState(mock(CalculatedFieldCtx.class), Map.of("geofencingArgumentTest", geofencingArgumentEntry)); |
||||
|
|
||||
|
// when
|
||||
|
CalculatedFieldStateProto proto = toProto(stateId, state); |
||||
|
|
||||
|
// then
|
||||
|
CalculatedFieldState fromProto = CalculatedFieldUtils.fromProto(proto); |
||||
|
assertThat(fromProto) |
||||
|
.usingRecursiveComparison() |
||||
|
.ignoringFields("requiredArguments") |
||||
|
.isEqualTo(state); |
||||
|
|
||||
|
ArgumentEntry fromProtoArgument = fromProto.getArguments().get("geofencingArgumentTest"); |
||||
|
assertThat(fromProtoArgument).isInstanceOf(GeofencingArgumentEntry.class); |
||||
|
GeofencingArgumentEntry fromProtoGeoArgument = (GeofencingArgumentEntry) fromProtoArgument; |
||||
|
assertThat(fromProtoGeoArgument.getZoneStates()).hasSize(2); |
||||
|
assertThat(fromProtoGeoArgument.getZoneStates().get(z1).getLastPresence()).isEqualTo(GeofencingPresenceStatus.INSIDE); |
||||
|
assertThat(fromProtoGeoArgument.getZoneStates().get(z2).getLastPresence()).isNull(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
|
||||
|
public interface ArgumentsBasedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { |
||||
|
|
||||
|
Map<String, Argument> getArguments(); |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonIgnore; |
||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes; |
||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo; |
||||
|
|
||||
|
@JsonTypeInfo( |
||||
|
use = JsonTypeInfo.Id.NAME, |
||||
|
include = JsonTypeInfo.As.PROPERTY, |
||||
|
property = "type" |
||||
|
) |
||||
|
@JsonSubTypes({ |
||||
|
@JsonSubTypes.Type(value = RelationQueryDynamicSourceConfiguration.class, name = "RELATION_QUERY") |
||||
|
}) |
||||
|
@JsonIgnoreProperties(ignoreUnknown = true) |
||||
|
public interface CfArgumentDynamicSourceConfiguration { |
||||
|
|
||||
|
@JsonIgnore |
||||
|
CFArgumentDynamicSourceType getType(); |
||||
|
|
||||
|
default void validate() {} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration; |
||||
|
|
||||
|
public interface ExpressionBasedCalculatedFieldConfiguration extends ArgumentsBasedCalculatedFieldConfiguration { |
||||
|
|
||||
|
String getExpression(); |
||||
|
|
||||
|
void setExpression(String expression); |
||||
|
|
||||
|
} |
||||
@ -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.common.data.cf.configuration; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonIgnore; |
||||
|
import lombok.Data; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.relation.EntityRelation; |
||||
|
import org.thingsboard.server.common.data.relation.EntityRelationsQuery; |
||||
|
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
||||
|
import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; |
||||
|
import org.thingsboard.server.common.data.relation.RelationsSearchParameters; |
||||
|
|
||||
|
import java.util.Collections; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class RelationQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { |
||||
|
|
||||
|
private int maxLevel; |
||||
|
private boolean fetchLastLevelOnly; |
||||
|
private EntitySearchDirection direction; |
||||
|
private String relationType; |
||||
|
|
||||
|
@Override |
||||
|
public CFArgumentDynamicSourceType getType() { |
||||
|
return CFArgumentDynamicSourceType.RELATION_QUERY; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void validate() { |
||||
|
if (maxLevel < 1) { |
||||
|
throw new IllegalArgumentException("Relation query dynamic source configuration max relation level can't be less than 1!"); |
||||
|
} |
||||
|
if (direction == null) { |
||||
|
throw new IllegalArgumentException("Relation query dynamic source configuration direction must be specified!"); |
||||
|
} |
||||
|
if (StringUtils.isBlank(relationType)) { |
||||
|
throw new IllegalArgumentException("Relation query dynamic source configuration relation type must be specified!"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@JsonIgnore |
||||
|
public boolean isSimpleRelation() { |
||||
|
return maxLevel == 1; |
||||
|
} |
||||
|
|
||||
|
public void validateMaxRelationLevel(String argumentName, int maxAllowedRelationLevel) { |
||||
|
if (maxLevel > maxAllowedRelationLevel) { |
||||
|
throw new IllegalArgumentException("Max relation level is greater than configured " + |
||||
|
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + argumentName); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public EntityRelationsQuery toEntityRelationsQuery(EntityId rootEntityId) { |
||||
|
if (isSimpleRelation()) { |
||||
|
throw new IllegalArgumentException("Entity relations query can't be created for a simple relation!"); |
||||
|
} |
||||
|
var entityRelationsQuery = new EntityRelationsQuery(); |
||||
|
entityRelationsQuery.setParameters(new RelationsSearchParameters(rootEntityId, direction, maxLevel, fetchLastLevelOnly)); |
||||
|
entityRelationsQuery.setFilters(Collections.singletonList(new RelationEntityTypeFilter(relationType, Collections.emptyList()))); |
||||
|
return entityRelationsQuery; |
||||
|
} |
||||
|
|
||||
|
public List<EntityId> resolveEntityIds(List<EntityRelation> relations) { |
||||
|
return switch (direction) { |
||||
|
case FROM -> relations.stream().map(EntityRelation::getTo).toList(); |
||||
|
case TO -> relations.stream().map(EntityRelation::getFrom).toList(); |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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.common.data.cf.configuration; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonIgnore; |
||||
|
|
||||
|
public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends CalculatedFieldConfiguration { |
||||
|
|
||||
|
@JsonIgnore |
||||
|
boolean isScheduledUpdateEnabled(); |
||||
|
|
||||
|
int getScheduledUpdateInterval(); |
||||
|
|
||||
|
void setScheduledUpdateInterval(int interval); |
||||
|
|
||||
|
default void validate(long minAllowedScheduledUpdateInterval) { |
||||
|
if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) { |
||||
|
throw new IllegalArgumentException("Scheduled update interval is less than configured " + |
||||
|
"minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
|
||||
|
import lombok.Data; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
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.ReferencedEntityKey; |
||||
|
|
||||
|
import java.util.Map; |
||||
|
|
||||
|
@Data |
||||
|
public class EntityCoordinates { |
||||
|
|
||||
|
public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude"; |
||||
|
public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude"; |
||||
|
|
||||
|
private final String latitudeKeyName; |
||||
|
private final String longitudeKeyName; |
||||
|
|
||||
|
public void validate() { |
||||
|
if (StringUtils.isBlank(latitudeKeyName)) { |
||||
|
throw new IllegalArgumentException("Entity coordinates latitude key name must be specified!"); |
||||
|
} |
||||
|
if (StringUtils.isBlank(longitudeKeyName)) { |
||||
|
throw new IllegalArgumentException("Entity coordinates longitude key name must be specified!"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public Map<String, Argument> toArguments() { |
||||
|
return Map.of( |
||||
|
ENTITY_ID_LATITUDE_ARGUMENT_KEY, toArgument(latitudeKeyName), |
||||
|
ENTITY_ID_LONGITUDE_ARGUMENT_KEY, toArgument(longitudeKeyName) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private Argument toArgument(String keyName) { |
||||
|
var argument = new Argument(); |
||||
|
argument.setRefEntityKey(new ReferencedEntityKey(keyName, ArgumentType.TS_LATEST, null)); |
||||
|
return argument; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonIgnore; |
||||
|
import lombok.Data; |
||||
|
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.Argument; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.Output; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.ScheduledUpdateSupportedCalculatedFieldConfiguration; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
|
||||
|
import java.util.HashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
|
||||
|
@Data |
||||
|
public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration { |
||||
|
|
||||
|
private EntityCoordinates entityCoordinates; |
||||
|
private Map<String, ZoneGroupConfiguration> zoneGroups; |
||||
|
private int scheduledUpdateInterval; |
||||
|
|
||||
|
private Output output; |
||||
|
|
||||
|
@Override |
||||
|
public CalculatedFieldType getType() { |
||||
|
return CalculatedFieldType.GEOFENCING; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
@JsonIgnore |
||||
|
public Map<String, Argument> getArguments() { |
||||
|
Map<String, Argument> args = new HashMap<>(entityCoordinates.toArguments()); |
||||
|
zoneGroups.forEach((zgName, zgConfig) -> args.put(zgName, zgConfig.toArgument())); |
||||
|
return args; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public List<EntityId> getReferencedEntities() { |
||||
|
return zoneGroups.values().stream().map(ZoneGroupConfiguration::getRefEntityId).filter(Objects::nonNull).toList(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public Output getOutput() { |
||||
|
return output; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean isScheduledUpdateEnabled() { |
||||
|
return scheduledUpdateInterval > 0 && zoneGroups.values().stream().anyMatch(ZoneGroupConfiguration::hasDynamicSource); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void validate() { |
||||
|
if (entityCoordinates == null) { |
||||
|
throw new IllegalArgumentException("Geofencing calculated field entity coordinates must be specified!"); |
||||
|
} |
||||
|
entityCoordinates.validate(); |
||||
|
if (zoneGroups == null || zoneGroups.isEmpty()) { |
||||
|
throw new IllegalArgumentException("Geofencing calculated field must contain at least one geofencing zone group defined!"); |
||||
|
} |
||||
|
zoneGroups.forEach((key, value) -> value.validate(key)); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
public sealed interface GeofencingEvent |
||||
|
permits GeofencingTransitionEvent, GeofencingPresenceStatus { } |
||||
@ -0,0 +1,25 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
import lombok.Getter; |
||||
|
|
||||
|
@Getter |
||||
|
public enum GeofencingPresenceStatus implements GeofencingEvent { |
||||
|
|
||||
|
INSIDE, OUTSIDE; |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
public enum GeofencingReportStrategy { |
||||
|
|
||||
|
REPORT_TRANSITION_EVENTS_ONLY, |
||||
|
REPORT_PRESENCE_STATUS_ONLY, |
||||
|
REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
public enum GeofencingTransitionEvent implements GeofencingEvent { |
||||
|
ENTERED, LEFT |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonIgnore; |
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
import lombok.Data; |
||||
|
import org.springframework.lang.Nullable; |
||||
|
import org.thingsboard.server.common.data.AttributeScope; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
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.CfArgumentDynamicSourceConfiguration; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; |
||||
|
import org.thingsboard.server.common.data.id.EntityId; |
||||
|
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
||||
|
|
||||
|
@Data |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public class ZoneGroupConfiguration { |
||||
|
|
||||
|
@Nullable |
||||
|
private EntityId refEntityId; |
||||
|
private CfArgumentDynamicSourceConfiguration refDynamicSourceConfiguration; |
||||
|
|
||||
|
private final String perimeterKeyName; |
||||
|
|
||||
|
private final GeofencingReportStrategy reportStrategy; |
||||
|
private final boolean createRelationsWithMatchedZones; |
||||
|
|
||||
|
private String relationType; |
||||
|
private EntitySearchDirection direction; |
||||
|
|
||||
|
public void validate(String name) { |
||||
|
if (EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY.equals(name) || EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY.equals(name)) { |
||||
|
throw new IllegalArgumentException("Name '" + name + "' is reserved and cannot be used for zone group!"); |
||||
|
} |
||||
|
if (StringUtils.isBlank(perimeterKeyName)) { |
||||
|
throw new IllegalArgumentException("Perimeter key name must be specified for '" + name + "' zone group!"); |
||||
|
} |
||||
|
if (reportStrategy == null) { |
||||
|
throw new IllegalArgumentException("Report strategy must be specified for '" + name + "' zone group!"); |
||||
|
} |
||||
|
if (hasDynamicSource()) { |
||||
|
refDynamicSourceConfiguration.validate(); |
||||
|
} |
||||
|
if (!createRelationsWithMatchedZones) { |
||||
|
return; |
||||
|
} |
||||
|
if (StringUtils.isBlank(relationType)) { |
||||
|
throw new IllegalArgumentException("Relation type must be specified for '" + name + "' zone group!"); |
||||
|
} |
||||
|
if (direction == null) { |
||||
|
throw new IllegalArgumentException("Relation direction must be specified for '" + name + "' zone group!"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public boolean hasDynamicSource() { |
||||
|
return refDynamicSourceConfiguration != null; |
||||
|
} |
||||
|
|
||||
|
@JsonIgnore |
||||
|
public boolean isCfEntitySource(EntityId cfEntityId) { |
||||
|
if (refEntityId == null && refDynamicSourceConfiguration == null) { |
||||
|
return true; |
||||
|
} |
||||
|
return refEntityId != null && refEntityId.equals(cfEntityId); |
||||
|
} |
||||
|
|
||||
|
@JsonIgnore |
||||
|
public boolean isLinkedCfEntitySource(EntityId cfEntityId) { |
||||
|
return refEntityId != null && !refEntityId.equals(cfEntityId); |
||||
|
} |
||||
|
|
||||
|
public Argument toArgument() { |
||||
|
var argument = new Argument(); |
||||
|
argument.setRefEntityId(refEntityId); |
||||
|
argument.setRefDynamicSourceConfiguration(refDynamicSourceConfiguration); |
||||
|
argument.setRefEntityKey(new ReferencedEntityKey(perimeterKeyName, ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
||||
|
return argument; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration; |
||||
|
|
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
public class ArgumentTest { |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldReturnFalseIfDynamicSourceConfigurationIsNull() { |
||||
|
var argument = new Argument(); |
||||
|
assertThat(argument.hasDynamicSource()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { |
||||
|
var argument = new Argument(); |
||||
|
argument.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); |
||||
|
assertThat(argument.hasDynamicSource()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,216 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.mockito.Mock; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.server.common.data.EntityType; |
||||
|
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.RelationEntityTypeFilter; |
||||
|
import org.thingsboard.server.common.data.relation.RelationsSearchParameters; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatCode; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class RelationQueryDynamicSourceConfigurationTest { |
||||
|
|
||||
|
@Mock |
||||
|
EntityId rootEntityId; |
||||
|
|
||||
|
@Mock |
||||
|
EntityRelation rel1; |
||||
|
@Mock |
||||
|
EntityRelation rel2; |
||||
|
|
||||
|
@Test |
||||
|
void typeShouldBeRelationQuery() { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
assertThat(cfg.getType()).isEqualTo(CFArgumentDynamicSourceType.RELATION_QUERY); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenMaxLevelLessThanOne() { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(0); |
||||
|
cfg.setDirection(EntitySearchDirection.FROM); |
||||
|
cfg.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
|
||||
|
assertThatThrownBy(cfg::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Relation query dynamic source configuration max relation level can't be less than 1!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenMaxLevelGreaterThanMaxAllowedLevelFromTenantProfile() { |
||||
|
int maxAllowedRelationLevel = 2; |
||||
|
int argumentMaxRelationLevel = 3; |
||||
|
|
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(argumentMaxRelationLevel); |
||||
|
cfg.setDirection(EntitySearchDirection.FROM); |
||||
|
cfg.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
|
||||
|
String testRelationArgument = "testRelationArgument"; |
||||
|
assertThatThrownBy(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Max relation level is greater than configured " + |
||||
|
"maximum allowed relation level in tenant profile: " + maxAllowedRelationLevel + " for argument: " + testRelationArgument); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldPassValidationWhenMaxLevelLessThanMaxAllowedLevelFromTenantProfile() { |
||||
|
int maxAllowedRelationLevel = 5; |
||||
|
int argumentMaxRelationLevel = 2; |
||||
|
|
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(argumentMaxRelationLevel); |
||||
|
cfg.setDirection(EntitySearchDirection.FROM); |
||||
|
cfg.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
|
||||
|
String testRelationArgument = "testRelationArgument"; |
||||
|
assertThatCode(() -> cfg.validateMaxRelationLevel(testRelationArgument, maxAllowedRelationLevel)).doesNotThrowAnyException(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenDirectionIsNull() { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(1); |
||||
|
cfg.setDirection(null); |
||||
|
cfg.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
|
||||
|
assertThatThrownBy(cfg::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Relation query dynamic source configuration direction must be specified!"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = {" "}) |
||||
|
@NullAndEmptySource |
||||
|
void validateShouldThrowWhenRelationTypeIsNull(String relationType) { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(1); |
||||
|
cfg.setDirection(EntitySearchDirection.TO); |
||||
|
cfg.setRelationType(relationType); |
||||
|
|
||||
|
assertThatThrownBy(cfg::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Relation query dynamic source configuration relation type must be specified!"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@NullAndEmptySource |
||||
|
void isSimpleRelationTrueWhenLevelIsOneAndEntityTypesEmptyOrNull(List<EntityType> entityTypes) { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(1); |
||||
|
assertThat(cfg.isSimpleRelation()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void isSimpleRelationFalseWhenMaxLevelNotOne() { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(2); |
||||
|
assertThat(cfg.isSimpleRelation()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@NullAndEmptySource |
||||
|
void toEntityRelationsQueryShouldThrowForSimpleRelation(List<EntityType> entityTypes) { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(1); |
||||
|
cfg.setFetchLastLevelOnly(false); |
||||
|
cfg.setDirection(EntitySearchDirection.FROM); |
||||
|
cfg.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
|
||||
|
assertThatThrownBy(() -> cfg.toEntityRelationsQuery(rootEntityId)) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Entity relations query can't be created for a simple relation!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void toEntityRelationsQueryShouldBuildQueryForNonSimpleRelation() { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(2); |
||||
|
cfg.setFetchLastLevelOnly(true); |
||||
|
cfg.setDirection(EntitySearchDirection.TO); |
||||
|
cfg.setRelationType(EntityRelation.MANAGES_TYPE); |
||||
|
|
||||
|
var query = cfg.toEntityRelationsQuery(rootEntityId); |
||||
|
|
||||
|
assertThat(query).isNotNull(); |
||||
|
RelationsSearchParameters params = query.getParameters(); |
||||
|
assertThat(params).isNotNull(); |
||||
|
assertThat(params.getRootId()).isEqualTo(rootEntityId.getId()); |
||||
|
assertThat(params.getDirection()).isEqualTo(EntitySearchDirection.TO); |
||||
|
assertThat(params.getMaxLevel()).isEqualTo(2); |
||||
|
assertThat(params.isFetchLastLevelOnly()).isTrue(); |
||||
|
|
||||
|
assertThat(query.getFilters()).hasSize(1); |
||||
|
assertThat(query.getFilters().get(0)).isInstanceOf(RelationEntityTypeFilter.class); |
||||
|
RelationEntityTypeFilter filter = query.getFilters().get(0); |
||||
|
assertThat(filter.getRelationType()).isEqualTo(EntityRelation.MANAGES_TYPE); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void resolveEntityIdsFromDirectionFROMReturnsToIds() { |
||||
|
when(rel1.getTo()).thenReturn(mock(EntityId.class)); |
||||
|
when(rel2.getTo()).thenReturn(mock(EntityId.class)); |
||||
|
|
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setDirection(EntitySearchDirection.FROM); |
||||
|
|
||||
|
var out = cfg.resolveEntityIds(List.of(rel1, rel2)); |
||||
|
|
||||
|
assertThat(out).containsExactly(rel1.getTo(), rel2.getTo()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void resolveEntityIdsFromDirectionTOReturnsFromIds() { |
||||
|
when(rel1.getFrom()).thenReturn(mock(EntityId.class)); |
||||
|
when(rel2.getFrom()).thenReturn(mock(EntityId.class)); |
||||
|
|
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setDirection(EntitySearchDirection.TO); |
||||
|
|
||||
|
var out = cfg.resolveEntityIds(List.of(rel1, rel2)); |
||||
|
|
||||
|
assertThat(out).containsExactly(rel1.getFrom(), rel2.getFrom()); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldPassForValidConfig() { |
||||
|
var cfg = new RelationQueryDynamicSourceConfiguration(); |
||||
|
cfg.setMaxLevel(2); |
||||
|
cfg.setFetchLastLevelOnly(false); |
||||
|
cfg.setDirection(EntitySearchDirection.FROM); |
||||
|
cfg.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
|
||||
|
assertThatCode(cfg::validate).doesNotThrowAnyException(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; |
||||
|
|
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThatCode; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenScheduledUpdateIntervalIsSetButTimeUnitIsNotSupported() { |
||||
|
int scheduledUpdateInterval = 60; |
||||
|
int minAllowedInterval = scheduledUpdateInterval - 1; |
||||
|
|
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
cfg.setScheduledUpdateInterval(scheduledUpdateInterval); |
||||
|
assertThatCode(() -> cfg.validate(minAllowedInterval)).doesNotThrowAnyException(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenScheduledUpdateIntervalIsLessThanMinAllowedIntervalInTenantProfile() { |
||||
|
int minAllowedInterval = (int) TimeUnit.HOURS.toSeconds(2); |
||||
|
|
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
cfg.setScheduledUpdateInterval(1); |
||||
|
|
||||
|
assertThatThrownBy(() -> cfg.validate(minAllowedInterval)) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Scheduled update interval is less than configured " + |
||||
|
"minimum allowed interval in tenant profile: " + minAllowedInterval); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,80 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
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.ReferencedEntityKey; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatCode; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; |
||||
|
|
||||
|
public class EntityCoordinatesTest { |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = " ") |
||||
|
@NullAndEmptySource |
||||
|
void validateShouldThrowWhenLatitudeCoordinateIsNullEmptyOrBlank(String latitudeKey) { |
||||
|
var entityCoordinates = new EntityCoordinates(latitudeKey, "longitude"); |
||||
|
assertThatThrownBy(entityCoordinates::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Entity coordinates latitude key name must be specified!"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = " ") |
||||
|
@NullAndEmptySource |
||||
|
void validateShouldThrowWhenLongitudeCoordinateIsNullEmptyOrBlank(String longitudeKey) { |
||||
|
var entityCoordinates = new EntityCoordinates("latitude", longitudeKey); |
||||
|
assertThatThrownBy(entityCoordinates::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Entity coordinates longitude key name must be specified!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldPassOnMinimalValidConfig() { |
||||
|
var entityCoordinates = new EntityCoordinates("latitude", "longitude"); |
||||
|
assertThatCode(entityCoordinates::validate).doesNotThrowAnyException(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateToArgumentsMethodCallWithoutRefEntityId() { |
||||
|
var entityCoordinates = new EntityCoordinates("xPos", "yPos"); |
||||
|
|
||||
|
var arguments = entityCoordinates.toArguments(); |
||||
|
assertThat(arguments).isNotNull().hasSize(2); |
||||
|
|
||||
|
Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY); |
||||
|
assertThat(latitudeArgument).isNotNull(); |
||||
|
assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("xPos", ArgumentType.TS_LATEST, null)); |
||||
|
assertThat(latitudeArgument.getRefEntityId()).isNull(); |
||||
|
assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull(); |
||||
|
|
||||
|
Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY); |
||||
|
assertThat(longitudeArgument).isNotNull(); |
||||
|
assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("yPos", ArgumentType.TS_LATEST, null)); |
||||
|
assertThat(longitudeArgument.getRefEntityId()).isNull(); |
||||
|
assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull(); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,161 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.api.extension.ExtendWith; |
||||
|
import org.mockito.junit.jupiter.MockitoExtension; |
||||
|
import org.thingsboard.server.common.data.AttributeScope; |
||||
|
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.ReferencedEntityKey; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatCode; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.never; |
||||
|
import static org.mockito.Mockito.verify; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY; |
||||
|
|
||||
|
@ExtendWith(MockitoExtension.class) |
||||
|
public class GeofencingCalculatedFieldConfigurationTest { |
||||
|
|
||||
|
@Test |
||||
|
void typeShouldBeGeofencing() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
assertThat(cfg.getType()).isEqualTo(CalculatedFieldType.GEOFENCING); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenEntityCoordinatesNull() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
cfg.setEntityCoordinates(null); |
||||
|
|
||||
|
assertThatThrownBy(cfg::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Geofencing calculated field entity coordinates must be specified!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenZoneGroupsNull() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); |
||||
|
cfg.setZoneGroups(null); |
||||
|
|
||||
|
assertThatThrownBy(cfg::validate) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Geofencing calculated field must contain at least one geofencing zone group defined!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroups() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); |
||||
|
cfg.setEntityCoordinates(entityCoordinatesMock); |
||||
|
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); |
||||
|
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfiguration)); |
||||
|
|
||||
|
cfg.validate(); |
||||
|
|
||||
|
verify(entityCoordinatesMock).validate(); |
||||
|
verify(zoneGroupConfiguration).validate("someGroupName"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldCallValidateOnEntityCoordinatesAndZoneGroupsWithoutAnyExceptions() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
EntityCoordinates entityCoordinatesMock = mock(EntityCoordinates.class); |
||||
|
cfg.setEntityCoordinates(entityCoordinatesMock); |
||||
|
var zoneGroupConfigurationA = mock(ZoneGroupConfiguration.class); |
||||
|
var zoneGroupConfigurationB = mock(ZoneGroupConfiguration.class); |
||||
|
|
||||
|
String zoneGroupAName = "zoneGroupA"; |
||||
|
String zoneGroupBName = "zoneGroupB"; |
||||
|
|
||||
|
cfg.setZoneGroups(Map.of("zoneGroupA", zoneGroupConfigurationA, "zoneGroupB", zoneGroupConfigurationB)); |
||||
|
|
||||
|
assertThatCode(cfg::validate).doesNotThrowAnyException(); |
||||
|
|
||||
|
verify(entityCoordinatesMock).validate(); |
||||
|
verify(zoneGroupConfigurationA).validate(zoneGroupAName); |
||||
|
verify(zoneGroupConfigurationB).validate(zoneGroupBName); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void scheduledUpdateDisabledWhenIntervalIsZero() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
cfg.setScheduledUpdateInterval(0); |
||||
|
assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void scheduledUpdateDisabledWhenIntervalIsGreaterThanZeroButNoZonesWithDynamicArguments() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class); |
||||
|
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(false); |
||||
|
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock)); |
||||
|
cfg.setScheduledUpdateInterval(60); |
||||
|
assertThat(cfg.isScheduledUpdateEnabled()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void scheduledUpdateEnabledWhenIntervalIsGreaterThanZeroAndDynamicArgumentsPresent() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
var zoneGroupConfigurationMock = mock(ZoneGroupConfiguration.class); |
||||
|
when(zoneGroupConfigurationMock.hasDynamicSource()).thenReturn(true); |
||||
|
cfg.setZoneGroups(Map.of("someGroupName", zoneGroupConfigurationMock)); |
||||
|
cfg.setScheduledUpdateInterval(60); |
||||
|
assertThat(cfg.isScheduledUpdateEnabled()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void testGetArgumentsOverride() { |
||||
|
var cfg = new GeofencingCalculatedFieldConfiguration(); |
||||
|
cfg.setEntityCoordinates(new EntityCoordinates(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY)); |
||||
|
cfg.setZoneGroups(Map.of("allowedZones", new ZoneGroupConfiguration("perimeter", GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false))); |
||||
|
|
||||
|
Map<String, Argument> arguments = cfg.getArguments(); |
||||
|
|
||||
|
assertThat(arguments).isNotNull().hasSize(3); |
||||
|
assertThat(arguments).containsKeys(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, "allowedZones"); |
||||
|
|
||||
|
Argument latitudeArgument = arguments.get(ENTITY_ID_LATITUDE_ARGUMENT_KEY); |
||||
|
assertThat(latitudeArgument).isNotNull(); |
||||
|
assertThat(latitudeArgument.getRefDynamicSourceConfiguration()).isNull(); |
||||
|
assertThat(latitudeArgument.getRefEntityId()).isNull(); |
||||
|
assertThat(latitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null)); |
||||
|
|
||||
|
Argument longitudeArgument = arguments.get(ENTITY_ID_LONGITUDE_ARGUMENT_KEY); |
||||
|
assertThat(longitudeArgument).isNotNull(); |
||||
|
assertThat(longitudeArgument.getRefDynamicSourceConfiguration()).isNull(); |
||||
|
assertThat(longitudeArgument.getRefEntityId()).isNull(); |
||||
|
assertThat(longitudeArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey(ENTITY_ID_LONGITUDE_ARGUMENT_KEY, ArgumentType.TS_LATEST, null)); |
||||
|
|
||||
|
Argument allowedZonesArgument = arguments.get("allowedZones"); |
||||
|
assertThat(allowedZonesArgument).isNotNull(); |
||||
|
assertThat(allowedZonesArgument.getRefDynamicSourceConfiguration()).isNull(); |
||||
|
assertThat(allowedZonesArgument.getRefEntityId()).isNull(); |
||||
|
assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,123 @@ |
|||||
|
/** |
||||
|
* Copyright © 2016-2025 The Thingsboard Authors |
||||
|
* |
||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
|
* you may not use this file except in compliance with the License. |
||||
|
* You may obtain a copy of the License at |
||||
|
* |
||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
* |
||||
|
* Unless required by applicable law or agreed to in writing, software |
||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
|
* See the License for the specific language governing permissions and |
||||
|
* limitations under the License. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.cf.configuration.geofencing; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.junit.jupiter.params.ParameterizedTest; |
||||
|
import org.junit.jupiter.params.provider.NullAndEmptySource; |
||||
|
import org.junit.jupiter.params.provider.ValueSource; |
||||
|
import org.thingsboard.server.common.data.AttributeScope; |
||||
|
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.ReferencedEntityKey; |
||||
|
import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration; |
||||
|
import org.thingsboard.server.common.data.relation.EntityRelation; |
||||
|
import org.thingsboard.server.common.data.relation.EntitySearchDirection; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
import static org.assertj.core.api.Assertions.assertThatCode; |
||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingReportStrategy.REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS; |
||||
|
|
||||
|
public class ZoneGroupConfigurationTest { |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = {EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY, EntityCoordinates.ENTITY_ID_LONGITUDE_ARGUMENT_KEY}) |
||||
|
void validateShouldThrowWhenUsedReservedEntityCoordinateNames(String name) { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); |
||||
|
assertThatThrownBy(() -> zoneGroupConfiguration.validate(name)) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Name '" + name + "' is reserved and cannot be used for zone group!"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = " ") |
||||
|
@NullAndEmptySource |
||||
|
void validateShouldThrowWhenPerimeterKeyNameIsNullEmptyOrBlank(String perimeterKeyName) { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration(perimeterKeyName, REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); |
||||
|
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Perimeter key name must be specified for 'allowedZonesGroup' zone group!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenReportStrategyIsNull() { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", null, false); |
||||
|
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Report strategy must be specified for 'allowedZonesGroup' zone group!"); |
||||
|
} |
||||
|
|
||||
|
@ParameterizedTest |
||||
|
@ValueSource(strings = " ") |
||||
|
@NullAndEmptySource |
||||
|
void validateShouldThrowWhenRelationCreationEnabledAndRelationTypeIsNullEmptyOrBlank(String relationType) { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); |
||||
|
zoneGroupConfiguration.setRelationType(relationType); |
||||
|
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Relation type must be specified for 'allowedZonesGroup' zone group!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldThrowWhenRelationCreationEnabledAndDirectionIsNull() { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); |
||||
|
zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
zoneGroupConfiguration.setDirection(null); |
||||
|
assertThatThrownBy(() -> zoneGroupConfiguration.validate("allowedZonesGroup")) |
||||
|
.isInstanceOf(IllegalArgumentException.class) |
||||
|
.hasMessage("Relation direction must be specified for 'allowedZonesGroup' zone group!"); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationDisabledAndConfigValid() { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); |
||||
|
assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void validateShouldDoesNotThrowAnyExceptionWhenRelationCreationEnabledAndConfigValid() { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, true); |
||||
|
zoneGroupConfiguration.setRelationType(EntityRelation.CONTAINS_TYPE); |
||||
|
zoneGroupConfiguration.setDirection(EntitySearchDirection.TO); |
||||
|
assertThatCode(() -> zoneGroupConfiguration.validate("allowedZonesGroup")).doesNotThrowAnyException(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNotNull() { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); |
||||
|
zoneGroupConfiguration.setRefDynamicSourceConfiguration(new RelationQueryDynamicSourceConfiguration()); |
||||
|
assertThat(zoneGroupConfiguration.hasDynamicSource()).isTrue(); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void whenHasDynamicSourceCalled_shouldReturnTrueIfDynamicSourceConfigurationIsNull() { |
||||
|
var zoneGroupConfiguration = mock(ZoneGroupConfiguration.class); |
||||
|
assertThat(zoneGroupConfiguration.getRefDynamicSourceConfiguration()).isNull(); |
||||
|
assertThat(zoneGroupConfiguration.hasDynamicSource()).isFalse(); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
@Test |
||||
|
void validateToArgumentsMethodCallWithoutRefEntityId() { |
||||
|
var zoneGroupConfiguration = new ZoneGroupConfiguration("perimeter", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); |
||||
|
Argument zoneGroupArgument = zoneGroupConfiguration.toArgument(); |
||||
|
assertThat(zoneGroupArgument).isNotNull(); |
||||
|
assertThat(zoneGroupArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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.script.api.tbel; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonCreator; |
||||
|
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class TbelCfTsGeofencingArg implements TbelCfArg { |
||||
|
|
||||
|
private final Object value; |
||||
|
|
||||
|
@JsonCreator |
||||
|
public TbelCfTsGeofencingArg(@JsonProperty("value") Object value) { |
||||
|
this.value = value; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public String getType() { |
||||
|
return "GEOFENCING_CF_ARGUMENT_VALUE"; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
@Override |
||||
|
public long memorySize() { |
||||
|
return OBJ_SIZE; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
/** |
||||
|
* 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.common.util.geo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class CirclePerimeterDefinition implements PerimeterDefinition { |
||||
|
|
||||
|
private final Double latitude; |
||||
|
private final Double longitude; |
||||
|
private final Double radius; |
||||
|
|
||||
|
@Override |
||||
|
public PerimeterType getType() { |
||||
|
return PerimeterType.CIRCLE; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean checkMatches(Coordinates entityCoordinates) { |
||||
|
Coordinates perimeterCoordinates = new Coordinates(latitude, longitude); |
||||
|
return radius > GeoUtil.distance(entityCoordinates, perimeterCoordinates, RangeUnit.METER); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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.common.util.geo; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonIgnore; |
||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; |
||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; |
||||
|
import com.fasterxml.jackson.databind.annotation.JsonSerialize; |
||||
|
|
||||
|
import java.io.Serializable; |
||||
|
|
||||
|
@JsonIgnoreProperties(ignoreUnknown = true) |
||||
|
@JsonDeserialize(using = PerimeterDefinitionDeserializer.class) |
||||
|
@JsonSerialize(using = PerimeterDefinitionSerializer.class) |
||||
|
public interface PerimeterDefinition extends Serializable { |
||||
|
|
||||
|
@JsonIgnore |
||||
|
PerimeterType getType(); |
||||
|
|
||||
|
@JsonIgnore |
||||
|
boolean checkMatches(Coordinates entityCoordinates); |
||||
|
} |
||||
@ -0,0 +1,48 @@ |
|||||
|
/** |
||||
|
* 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.common.util.geo; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.JsonParser; |
||||
|
import com.fasterxml.jackson.core.ObjectCodec; |
||||
|
import com.fasterxml.jackson.databind.DeserializationContext; |
||||
|
import com.fasterxml.jackson.databind.JsonDeserializer; |
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
|
||||
|
public class PerimeterDefinitionDeserializer extends JsonDeserializer<PerimeterDefinition> { |
||||
|
|
||||
|
@Override |
||||
|
public PerimeterDefinition deserialize(JsonParser p, DeserializationContext ctx) throws IOException { |
||||
|
ObjectCodec codec = p.getCodec(); |
||||
|
JsonNode node = codec.readTree(p); |
||||
|
|
||||
|
if (node.isObject()) { |
||||
|
double latitude = node.get("latitude").asDouble(); |
||||
|
double longitude = node.get("longitude").asDouble(); |
||||
|
double radius = node.get("radius").asDouble(); |
||||
|
return new CirclePerimeterDefinition(latitude, longitude, radius); |
||||
|
} |
||||
|
if (node.isArray()) { |
||||
|
ObjectMapper mapper = (ObjectMapper) p.getCodec(); |
||||
|
String polygonStrDefinition = mapper.writeValueAsString(node); |
||||
|
return new PolygonPerimeterDefinition(polygonStrDefinition); |
||||
|
} |
||||
|
throw new IOException("Failed to deserialize PerimeterDefinition from node: " + node); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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.common.util.geo; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.JsonGenerator; |
||||
|
import com.fasterxml.jackson.databind.JsonSerializer; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.fasterxml.jackson.databind.SerializerProvider; |
||||
|
import org.thingsboard.server.common.data.StringUtils; |
||||
|
|
||||
|
import java.io.IOException; |
||||
|
|
||||
|
public class PerimeterDefinitionSerializer extends JsonSerializer<PerimeterDefinition> { |
||||
|
|
||||
|
@Override |
||||
|
public void serialize(PerimeterDefinition value, JsonGenerator gen, SerializerProvider serializers) throws IOException { |
||||
|
if (value instanceof CirclePerimeterDefinition c) { |
||||
|
gen.writeStartObject(); |
||||
|
gen.writeNumberField("latitude", c.getLatitude()); |
||||
|
gen.writeNumberField("longitude", c.getLongitude()); |
||||
|
gen.writeNumberField("radius", c.getRadius()); |
||||
|
gen.writeEndObject(); |
||||
|
return; |
||||
|
} |
||||
|
if (value instanceof PolygonPerimeterDefinition p) { |
||||
|
String raw = p.getPolygonDefinition(); |
||||
|
if (StringUtils.isBlank(raw)) { |
||||
|
throw new IOException("Failed to serialize PolygonPerimeterDefinition with blank: " + value); |
||||
|
} |
||||
|
ObjectMapper mapper = (ObjectMapper) gen.getCodec(); |
||||
|
gen.writeTree(mapper.readTree(raw)); |
||||
|
return; |
||||
|
} |
||||
|
throw new IOException("Failed to serialize PerimeterDefinition from value: " + value); |
||||
|
} |
||||
|
} |
||||
@ -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.common.util.geo; |
||||
|
|
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class PolygonPerimeterDefinition implements PerimeterDefinition { |
||||
|
|
||||
|
private final String polygonDefinition; |
||||
|
|
||||
|
@Override |
||||
|
public PerimeterType getType() { |
||||
|
return PerimeterType.POLYGON; |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public boolean checkMatches(Coordinates entityCoordinates) { |
||||
|
return GeoUtil.contains(polygonDefinition, entityCoordinates); |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
/** |
||||
|
* 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.common.util.geo; |
||||
|
|
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
public class PerimeterDefinitionDeserializerTest { |
||||
|
|
||||
|
@Test |
||||
|
void shouldDeserializeCircle() { |
||||
|
String json = """ |
||||
|
{"latitude":50.45,"longitude":30.52,"radius":100.0}"""; |
||||
|
|
||||
|
PerimeterDefinition def = JacksonUtil.fromString(json, PerimeterDefinition.class); |
||||
|
|
||||
|
assertThat(def).isNotNull().isInstanceOf(CirclePerimeterDefinition.class); |
||||
|
|
||||
|
CirclePerimeterDefinition circle = (CirclePerimeterDefinition) def; |
||||
|
assertThat(circle.getLatitude()).isEqualTo(50.45); |
||||
|
assertThat(circle.getLongitude()).isEqualTo(30.52); |
||||
|
assertThat(circle.getRadius()).isEqualTo(100.0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void shouldDeserializePolygon() { |
||||
|
String json = "[[50.45,30.52],[50.46,30.53],[50.44,30.54]]"; |
||||
|
|
||||
|
PerimeterDefinition def = JacksonUtil.fromString(json, PerimeterDefinition.class); |
||||
|
|
||||
|
assertThat(def).isInstanceOf(PolygonPerimeterDefinition.class); |
||||
|
PolygonPerimeterDefinition poly = (PolygonPerimeterDefinition) def; |
||||
|
assertThat(poly.getPolygonDefinition()).isEqualTo(json); |
||||
|
} |
||||
|
} |
||||
@ -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.common.util.geo; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.thingsboard.common.util.JacksonUtil; |
||||
|
|
||||
|
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
|
||||
|
public class PerimeterDefinitionSerializerTest { |
||||
|
|
||||
|
@Test |
||||
|
void shouldSerializeCircle() { |
||||
|
PerimeterDefinition circle = new CirclePerimeterDefinition(50.45, 30.52, 120.0); |
||||
|
|
||||
|
String json = JacksonUtil.writeValueAsString(circle); |
||||
|
|
||||
|
JsonNode actual = JacksonUtil.toJsonNode(json); |
||||
|
assertThat(actual.get("latitude").asDouble()).isEqualTo(50.45); |
||||
|
assertThat(actual.get("longitude").asDouble()).isEqualTo(30.52); |
||||
|
assertThat(actual.get("radius").asDouble()).isEqualTo(120.0); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void shouldSerializePolygon() throws Exception { |
||||
|
String rawArray = "[[50.45,30.52],[50.46,30.53],[50.44,30.54]]"; |
||||
|
PerimeterDefinition polygon = new PolygonPerimeterDefinition(rawArray); |
||||
|
|
||||
|
String json = JacksonUtil.writeValueAsString(polygon); |
||||
|
|
||||
|
JsonNode actual = JacksonUtil.toJsonNode(json); |
||||
|
JsonNode expected = JacksonUtil.toJsonNode(rawArray); |
||||
|
assertThat(actual).isEqualTo(expected); |
||||
|
assertThat(actual.isArray()).isTrue(); |
||||
|
assertThat(actual.size()).isEqualTo(3); |
||||
|
} |
||||
|
|
||||
|
} |
||||
Loading…
Reference in new issue