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