committed by
GitHub
113 changed files with 6173 additions and 527 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,257 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.cf; |
|||
|
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import com.google.common.util.concurrent.ListeningExecutorService; |
|||
import com.google.common.util.concurrent.MoreExecutors; |
|||
import jakarta.annotation.PostConstruct; |
|||
import jakarta.annotation.PreDestroy; |
|||
import lombok.Data; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.thingsboard.common.util.ThingsBoardExecutors; |
|||
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.RelationQueryDynamicSourceConfiguration; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.kv.Aggregation; |
|||
import org.thingsboard.server.common.data.kv.AttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; |
|||
import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; |
|||
import org.thingsboard.server.common.data.kv.BasicTsKvEntry; |
|||
import org.thingsboard.server.common.data.kv.ReadTsKvQuery; |
|||
import org.thingsboard.server.common.data.kv.TsKvEntry; |
|||
import org.thingsboard.server.common.data.relation.RelationTypeGroup; |
|||
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; |
|||
import org.thingsboard.server.dao.attributes.AttributesService; |
|||
import org.thingsboard.server.dao.relation.RelationService; |
|||
import org.thingsboard.server.dao.timeseries.TimeseriesService; |
|||
import org.thingsboard.server.dao.usagerecord.ApiLimitService; |
|||
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 java.util.HashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.concurrent.ExecutionException; |
|||
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.utils.CalculatedFieldArgumentUtils.createDefaultKvEntry; |
|||
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.createStateByType; |
|||
import static org.thingsboard.server.utils.CalculatedFieldArgumentUtils.transformSingleValueArgument; |
|||
|
|||
@Data |
|||
@Slf4j |
|||
public abstract class AbstractCalculatedFieldProcessingService { |
|||
|
|||
protected final AttributesService attributesService; |
|||
protected final TimeseriesService timeseriesService; |
|||
protected final ApiLimitService apiLimitService; |
|||
protected final RelationService relationService; |
|||
|
|||
protected ListeningExecutorService calculatedFieldCallbackExecutor; |
|||
|
|||
@PostConstruct |
|||
public void init() { |
|||
calculatedFieldCallbackExecutor = MoreExecutors.listeningDecorator(ThingsBoardExecutors.newWorkStealingPool( |
|||
Math.max(4, Runtime.getRuntime().availableProcessors()), getExecutorNamePrefix())); |
|||
} |
|||
|
|||
@PreDestroy |
|||
public void stop() { |
|||
if (calculatedFieldCallbackExecutor != null) { |
|||
calculatedFieldCallbackExecutor.shutdownNow(); |
|||
} |
|||
} |
|||
|
|||
protected abstract String getExecutorNamePrefix(); |
|||
|
|||
public ListenableFuture<CalculatedFieldState> fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) { |
|||
Map<String, ListenableFuture<ArgumentEntry>> argFutures = switch (ctx.getCalculatedField().getType()) { |
|||
case GEOFENCING -> fetchGeofencingCalculatedFieldArguments(ctx, entityId, false); |
|||
case SIMPLE, SCRIPT -> { |
|||
Map<String, ListenableFuture<ArgumentEntry>> futures = new HashMap<>(); |
|||
for (var entry : ctx.getArguments().entrySet()) { |
|||
var argEntityId = resolveEntityId(entityId, entry.getValue()); |
|||
var argValueFuture = fetchArgumentValue(ctx.getTenantId(), argEntityId, entry.getValue(), System.currentTimeMillis()); |
|||
futures.put(entry.getKey(), argValueFuture); |
|||
} |
|||
yield futures; |
|||
} |
|||
}; |
|||
return Futures.whenAllComplete(argFutures.values()).call(() -> { |
|||
var result = createStateByType(ctx); |
|||
result.updateState(ctx, resolveArgumentFutures(argFutures)); |
|||
return result; |
|||
}, MoreExecutors.directExecutor()); |
|||
} |
|||
|
|||
protected EntityId resolveEntityId(EntityId entityId, Argument argument) { |
|||
return argument.getRefEntityId() != null ? argument.getRefEntityId() : entityId; |
|||
} |
|||
|
|||
protected Map<String, ArgumentEntry> resolveArgumentFutures(Map<String, ListenableFuture<ArgumentEntry>> argFutures) { |
|||
return argFutures.entrySet().stream() |
|||
.collect(Collectors.toMap( |
|||
Map.Entry::getKey, // Keep the key as is
|
|||
entry -> { |
|||
try { |
|||
return entry.getValue().get(); |
|||
} catch (ExecutionException e) { |
|||
Throwable cause = e.getCause(); |
|||
throw new RuntimeException("Failed to fetch " + entry.getKey() + ": " + cause.getMessage(), cause); |
|||
} catch (InterruptedException e) { |
|||
throw new RuntimeException("Failed to fetch" + entry.getKey(), e); |
|||
} |
|||
} |
|||
)); |
|||
} |
|||
|
|||
protected Map<String, ListenableFuture<ArgumentEntry>> fetchGeofencingCalculatedFieldArguments(CalculatedFieldCtx ctx, EntityId entityId, boolean dynamicArgumentsOnly) { |
|||
Map<String, ListenableFuture<ArgumentEntry>> argFutures = new HashMap<>(); |
|||
Set<Map.Entry<String, Argument>> entries = ctx.getArguments().entrySet(); |
|||
if (dynamicArgumentsOnly) { |
|||
entries = entries.stream() |
|||
.filter(entry -> entry.getValue().hasDynamicSource()) |
|||
.collect(Collectors.toSet()); |
|||
} |
|||
for (var entry : entries) { |
|||
switch (entry.getKey()) { |
|||
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> |
|||
argFutures.put(entry.getKey(), fetchArgumentValue(ctx.getTenantId(), entityId, entry.getValue(), System.currentTimeMillis())); |
|||
default -> { |
|||
var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry); |
|||
argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds -> |
|||
fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue()), MoreExecutors.directExecutor())); |
|||
} |
|||
} |
|||
} |
|||
return argFutures; |
|||
} |
|||
|
|||
private ListenableFuture<List<EntityId>> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Map.Entry<String, Argument> entry) { |
|||
Argument value = entry.getValue(); |
|||
if (value.getRefEntityId() != null) { |
|||
return Futures.immediateFuture(List.of(value.getRefEntityId())); |
|||
} |
|||
if (!value.hasDynamicSource()) { |
|||
return Futures.immediateFuture(List.of(entityId)); |
|||
} |
|||
var refDynamicSourceConfiguration = value.getRefDynamicSourceConfiguration(); |
|||
return switch (refDynamicSourceConfiguration.getType()) { |
|||
case RELATION_QUERY -> { |
|||
var configuration = (RelationQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; |
|||
if (configuration.isSimpleRelation()) { |
|||
yield switch (configuration.getDirection()) { |
|||
case FROM -> |
|||
Futures.transform(relationService.findByFromAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON), |
|||
configuration::resolveEntityIds, calculatedFieldCallbackExecutor); |
|||
case TO -> |
|||
Futures.transform(relationService.findByToAndTypeAsync(tenantId, entityId, configuration.getRelationType(), RelationTypeGroup.COMMON), |
|||
configuration::resolveEntityIds, calculatedFieldCallbackExecutor); |
|||
}; |
|||
} |
|||
yield Futures.transform(relationService.findByQuery(tenantId, configuration.toEntityRelationsQuery(entityId)), |
|||
configuration::resolveEntityIds, calculatedFieldCallbackExecutor); |
|||
} |
|||
}; |
|||
} |
|||
|
|||
private ListenableFuture<ArgumentEntry> fetchGeofencingKvEntry(TenantId tenantId, List<EntityId> geofencingEntities, Argument argument) { |
|||
if (argument.getRefEntityKey().getType() != ArgumentType.ATTRIBUTE) { |
|||
throw new IllegalStateException("Unsupported argument key type: " + argument.getRefEntityKey().getType()); |
|||
} |
|||
List<ListenableFuture<Map.Entry<EntityId, AttributeKvEntry>>> kvFutures = geofencingEntities.stream() |
|||
.map(entityId -> { |
|||
var attributesFuture = attributesService.find( |
|||
tenantId, |
|||
entityId, |
|||
argument.getRefEntityKey().getScope(), |
|||
argument.getRefEntityKey().getKey() |
|||
); |
|||
return Futures.transform(attributesFuture, resultOpt -> |
|||
Map.entry(entityId, resultOpt.orElseGet(() -> |
|||
new BaseAttributeKvEntry(createDefaultKvEntry(argument), System.currentTimeMillis(), 0L))), |
|||
calculatedFieldCallbackExecutor |
|||
); |
|||
}).collect(Collectors.toList()); |
|||
|
|||
ListenableFuture<List<Map.Entry<EntityId, AttributeKvEntry>>> allFutures = Futures.allAsList(kvFutures); |
|||
|
|||
return Futures.transform(allFutures, entries -> ArgumentEntry.createGeofencingValueArgument(entries.stream() |
|||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))), MoreExecutors.directExecutor()); |
|||
} |
|||
|
|||
protected ListenableFuture<ArgumentEntry> fetchArgumentValue(TenantId tenantId, EntityId entityId, Argument argument, long startTs) { |
|||
return switch (argument.getRefEntityKey().getType()) { |
|||
case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument, startTs); |
|||
case ATTRIBUTE -> fetchAttribute(tenantId, entityId, argument, startTs); |
|||
case TS_LATEST -> fetchTsLatest(tenantId, entityId, argument, startTs); |
|||
}; |
|||
} |
|||
|
|||
private ListenableFuture<ArgumentEntry> fetchTsRolling(TenantId tenantId, EntityId entityId, Argument argument, long queryEndTs) { |
|||
long argTimeWindow = argument.getTimeWindow() == 0 ? queryEndTs : argument.getTimeWindow(); |
|||
long startInterval = queryEndTs - argTimeWindow; |
|||
ReadTsKvQuery query = buildTsRollingQuery(tenantId, argument, startInterval, queryEndTs); |
|||
|
|||
log.trace("[{}][{}] Fetching timeseries for query {}", tenantId, entityId, query); |
|||
ListenableFuture<List<TsKvEntry>> tsRollingFuture = timeseriesService.findAll(tenantId, entityId, List.of(query)); |
|||
return Futures.transform(tsRollingFuture, tsRolling -> { |
|||
log.debug("[{}][{}] Fetched {} timeseries for query {}", tenantId, entityId, tsRolling == null ? 0 : tsRolling.size(), query); |
|||
return ArgumentEntry.createTsRollingArgument(tsRolling, query.getLimit(), argTimeWindow); |
|||
}, calculatedFieldCallbackExecutor); |
|||
} |
|||
|
|||
private ListenableFuture<ArgumentEntry> fetchAttribute(TenantId tenantId, EntityId entityId, Argument argument, long defaultLastUpdateTs) { |
|||
log.trace("[{}][{}] Fetching attribute for key {}", tenantId, entityId, argument.getRefEntityKey()); |
|||
var attributeOptFuture = attributesService.find(tenantId, entityId, argument.getRefEntityKey().getScope(), argument.getRefEntityKey().getKey()); |
|||
|
|||
return Futures.transform(attributeOptFuture, attrOpt -> { |
|||
log.debug("[{}][{}] Fetched attribute for key {}: {}", tenantId, entityId, argument.getRefEntityKey(), attrOpt); |
|||
AttributeKvEntry attributeKvEntry = attrOpt.orElseGet(() -> new BaseAttributeKvEntry(createDefaultKvEntry(argument), defaultLastUpdateTs, 0L)); |
|||
return transformSingleValueArgument(Optional.of(attributeKvEntry)); |
|||
}, calculatedFieldCallbackExecutor); |
|||
} |
|||
|
|||
protected ListenableFuture<ArgumentEntry> fetchTsLatest(TenantId tenantId, EntityId entityId, Argument argument, long startTs) { |
|||
String timeseriesKey = argument.getRefEntityKey().getKey(); |
|||
log.trace("[{}][{}] Fetching latest timeseries {}", tenantId, entityId, timeseriesKey); |
|||
return transformSingleValueArgument( |
|||
Futures.transform( |
|||
timeseriesService.findLatest(tenantId, entityId, timeseriesKey), |
|||
result -> { |
|||
log.debug("[{}][{}] Fetched latest timeseries {}: {}", tenantId, entityId, timeseriesKey, result); |
|||
return result.or(() -> Optional.of(new BasicTsKvEntry(System.currentTimeMillis(), createDefaultKvEntry(argument), 0L))); |
|||
}, calculatedFieldCallbackExecutor)); |
|||
} |
|||
|
|||
private ReadTsKvQuery buildTsRollingQuery(TenantId tenantId, Argument argument, long startTs, long endTs) { |
|||
long maxDataPoints = apiLimitService.getLimit( |
|||
tenantId, DefaultTenantProfileConfiguration::getMaxDataPointsPerRollingArg); |
|||
int argumentLimit = argument.getLimit(); |
|||
int limit = argumentLimit == 0 || argumentLimit > maxDataPoints ? (int) maxDataPoints : argumentLimit; |
|||
return new BaseReadTsKvQuery(argument.getRefEntityKey().getKey(), startTs, endTs, 0, limit, Aggregation.NONE); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,103 @@ |
|||
/** |
|||
* 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() |
|||
.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.getValueAsString(), 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,75 @@ |
|||
/** |
|||
* 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, CalculatedFieldArgumentUtils::transformSingleValueArgument, MoreExecutors.directExecutor()); |
|||
} |
|||
|
|||
public static ArgumentEntry transformSingleValueArgument(Optional<? extends KvEntry> kvEntry) { |
|||
if (kvEntry.isPresent() && kvEntry.get().getValue() != null) { |
|||
return ArgumentEntry.createSingleValueArgument(kvEntry.get()); |
|||
} else { |
|||
return new SingleValueArgumentEntry(); |
|||
} |
|||
} |
|||
|
|||
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,184 @@ |
|||
/** |
|||
* 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); |
|||
assertThatThrownBy(() -> new GeofencingArgumentEntry(Map.of(ZONE_1_ID, invalidZoneEntry))) |
|||
.isExactlyInstanceOf(IllegalArgumentException.class) |
|||
.hasMessage("The given string value cannot be transformed to Json object: someString"); |
|||
} |
|||
|
|||
@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); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,146 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2025 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="flex flex-col gap-3"> |
|||
<div class="tb-form-panel stroked no-padding no-gap arguments-table flex flex-col" [class.arguments-table-with-error]="errorText"> |
|||
<table mat-table [dataSource]="dataSource" class="overflow-hidden bg-transparent" matSort |
|||
[matSortActive]="sortOrder.property" [matSortDirection]="sortOrder.direction" matSortDisableClear> |
|||
<ng-container [matColumnDef]="'name'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="!w-1/3 xs:!w-1/2"> |
|||
<div tbTruncateWithTooltip>{{ 'common.name' | translate }}</div> |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let geofenceZone" class="argument-name-cell w-1/3 xs:w-1/2"> |
|||
<div class="flex items-center"> |
|||
<div tbTruncateWithTooltip class="flex-1">{{ geofenceZone.name }}</div> |
|||
<tb-copy-button class="copy-argument-name" |
|||
[copyText]="geofenceZone.name" |
|||
tooltipText="{{ 'calculated-fields.copy-zone-group-name' | translate }}" |
|||
tooltipPosition="above" |
|||
icon="content_copy"/> |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container [matColumnDef]="'entityType'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="entity-type-header w-1/5 xs:hidden"> |
|||
{{ 'entity.entity-type' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let geofenceZone" class="w-1/5 xs:hidden"> |
|||
<div tbTruncateWithTooltip> |
|||
@if (geofenceZone.refEntityId?.entityType === ArgumentEntityType.Tenant) { |
|||
{{ 'calculated-fields.argument-current-tenant' | translate }} |
|||
} @else if (geofenceZone.refDynamicSourceConfiguration?.type === ArgumentEntityType.RelationQuery) { |
|||
{{ 'calculated-fields.argument-relation-query' | translate }} |
|||
} @else if (geofenceZone.refEntityId?.id) { |
|||
{{ entityTypeTranslations.get(geofenceZone.refEntityId.entityType).type | translate }} |
|||
} @else { |
|||
{{ 'calculated-fields.argument-current' | translate }} |
|||
} |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<ng-container [matColumnDef]="'target'"> |
|||
<mat-header-cell *matHeaderCellDef class="w-1/4 xs:hidden"> |
|||
{{ 'calculated-fields.target-zone' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 xs:hidden"> |
|||
<div tbTruncateWithTooltip> |
|||
@if (geofenceZone.refEntityId?.id && geofenceZone.refEntityId?.entityType !== ArgumentEntityType.Tenant) { |
|||
<a [attr.aria-label]="'calculated-fields.open-details-page' | translate" |
|||
[routerLink]="getEntityDetailsPageURL(geofenceZone.refEntityId.id, geofenceZone.refEntityId.entityType)"> |
|||
{{ entityNameMap.get(geofenceZone.refEntityId.id) ?? '' }} |
|||
</a> |
|||
} |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
|
|||
<ng-container [matColumnDef]="'key'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 xs:w-1/3"> |
|||
{{ 'calculated-fields.perimeter-key' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 xs:w-1/3"> |
|||
<mat-chip class="tb-chip-row-ellipsis"> |
|||
<div tbTruncateWithTooltip class="key-text">{{ geofenceZone.perimeterKeyName }}</div> |
|||
</mat-chip> |
|||
</mat-cell> |
|||
</ng-container> |
|||
|
|||
<ng-container [matColumnDef]="'reportStrategy'"> |
|||
<mat-header-cell mat-sort-header *matHeaderCellDef class="w-1/4 lt-md:hidden"> |
|||
{{ 'calculated-fields.report-strategy' | translate }} |
|||
</mat-header-cell> |
|||
<mat-cell *matCellDef="let geofenceZone" class="w-1/4 lt-md:hidden"> |
|||
<div tbTruncateWithTooltip>{{ GeofencingReportStrategyTranslations.get(geofenceZone.reportStrategy) | translate }}</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
|
|||
<ng-container matColumnDef="actions" stickyEnd> |
|||
<mat-header-cell *matHeaderCellDef class="w-20 min-w-20"/> |
|||
<mat-cell *matCellDef="let geofenceZone;"> |
|||
<div class="tb-form-table-row-cell-buttons flex w-20 min-w-20"> |
|||
<button type="button" |
|||
mat-icon-button |
|||
#button |
|||
(click)="manageZone($event, button, geofenceZone)" |
|||
[matTooltip]="'action.edit' | translate" |
|||
matTooltipPosition="above"> |
|||
<mat-icon [matBadgeHidden]="geofenceZone.refEntityId?.id !== NULL_UUID" |
|||
matBadgeColor="warn" |
|||
matBadgeSize="small" |
|||
matBadge="*"> |
|||
edit |
|||
</mat-icon> |
|||
</button> |
|||
<button type="button" |
|||
mat-icon-button |
|||
(click)="onDelete($event, geofenceZone)" |
|||
[matTooltip]="'action.delete' | translate" |
|||
matTooltipPosition="above"> |
|||
<mat-icon>delete</mat-icon> |
|||
</button> |
|||
</div> |
|||
</mat-cell> |
|||
</ng-container> |
|||
<mat-header-row class="mat-row-select" |
|||
*matHeaderRowDef="['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-header-row> |
|||
<mat-row *matRowDef="let argument; columns: ['name', 'entityType', 'target', 'key', 'reportStrategy', 'actions']"></mat-row> |
|||
</table> |
|||
<div [class.!hidden]="(dataSource.isEmpty() | async) === false" |
|||
class="tb-prompt flex flex-1 items-end justify-center"> |
|||
{{ 'calculated-fields.no-zone-configured' | translate }} |
|||
</div> |
|||
@if (errorText) { |
|||
<tb-error noMargin [error]="errorText | translate" class="flex h-9 items-center pl-3"/> |
|||
} |
|||
</div> |
|||
<div class="flex h-9 justify-between"> |
|||
<button type="button" |
|||
mat-stroked-button |
|||
color="primary" |
|||
#button |
|||
(click)="manageZone($event, button)" |
|||
[disabled]="maxArgumentsPerCF > 0 && zoneGroupsFormArray.length >= maxArgumentsPerCF"> |
|||
{{ 'calculated-fields.add-zone-group' | translate }} |
|||
</button> |
|||
@if (maxArgumentsPerCF && zoneGroupsFormArray.length >= maxArgumentsPerCF) { |
|||
<div class="tb-form-hint tb-primary-fill max-args-warning flex items-center gap-2"> |
|||
<mat-icon>warning</mat-icon> |
|||
<span>{{ 'calculated-fields.hint.max-geofencing-zone' | translate }}</span> |
|||
</div> |
|||
} |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,76 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0 |
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
:host { |
|||
.arguments-table { |
|||
min-height: 108px; |
|||
|
|||
&-with-error { |
|||
min-height: 150px; |
|||
} |
|||
|
|||
.mat-mdc-table { |
|||
table-layout: fixed; |
|||
} |
|||
|
|||
.key-text { |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.copy-argument-name { |
|||
visibility: hidden; |
|||
transition: visibility 0.1s; |
|||
} |
|||
|
|||
.argument-name-cell:hover { |
|||
.copy-argument-name { |
|||
visibility: visible; |
|||
} |
|||
} |
|||
} |
|||
|
|||
.max-args-warning { |
|||
.mat-icon { |
|||
color: #FAA405; |
|||
} |
|||
} |
|||
|
|||
.tb-form-table-row-cell-buttons { |
|||
--mat-badge-legacy-small-size-container-size: 8px; |
|||
--mat-badge-small-size-container-overlap-offset: -5px; |
|||
--mat-badge-small-size-text-size: 0; |
|||
} |
|||
} |
|||
|
|||
:host ::ng-deep { |
|||
.arguments-table:not(.arguments-table-with-error) { |
|||
.mdc-data-table__row:last-child .mat-mdc-cell { |
|||
border-bottom: none; |
|||
} |
|||
} |
|||
|
|||
.arguments-table { |
|||
.mat-mdc-header-row.mat-row-select .mat-mdc-header-cell.entity-type-header { |
|||
padding: 0 28px 0 0; |
|||
} |
|||
} |
|||
|
|||
.copy-argument-name { |
|||
.mat-icon { |
|||
font-size: 16px; |
|||
padding: 4px; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,307 @@ |
|||
///
|
|||
/// Copyright © 2016-2025 The Thingsboard Authors
|
|||
///
|
|||
/// Licensed under the Apache License, Version 2.0 (the "License");
|
|||
/// you may not use this file except in compliance with the License.
|
|||
/// You may obtain a copy of the License at
|
|||
///
|
|||
/// http://www.apache.org/licenses/LICENSE-2.0
|
|||
///
|
|||
/// Unless required by applicable law or agreed to in writing, software
|
|||
/// distributed under the License is distributed on an "AS IS" BASIS,
|
|||
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|||
/// See the License for the specific language governing permissions and
|
|||
/// limitations under the License.
|
|||
///
|
|||
|
|||
import { |
|||
AfterViewInit, |
|||
ChangeDetectorRef, |
|||
Component, |
|||
DestroyRef, |
|||
forwardRef, |
|||
Input, |
|||
Renderer2, |
|||
ViewChild, |
|||
ViewContainerRef, |
|||
} from '@angular/core'; |
|||
import { |
|||
ControlValueAccessor, |
|||
FormBuilder, |
|||
NG_VALIDATORS, |
|||
NG_VALUE_ACCESSOR, |
|||
ValidationErrors, |
|||
Validator, |
|||
} from '@angular/forms'; |
|||
import { |
|||
ArgumentEntityType, |
|||
CalculatedFieldGeofencing, |
|||
CalculatedFieldGeofencingValue, |
|||
CalculatedFieldType, |
|||
GeofencingReportStrategyTranslations, |
|||
} from '@shared/models/calculated-field.models'; |
|||
import { MatButton } from '@angular/material/button'; |
|||
import { TbPopoverService } from '@shared/components/popover.service'; |
|||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; |
|||
import { EntityId } from '@shared/models/id/entity-id'; |
|||
import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; |
|||
import { getEntityDetailsPageURL, isEqual } from '@core/utils'; |
|||
import { TbPopoverComponent } from '@shared/components/popover.component'; |
|||
import { TbTableDatasource } from '@shared/components/table/table-datasource.abstract'; |
|||
import { EntityService } from '@core/http/entity.service'; |
|||
import { MatSort } from '@angular/material/sort'; |
|||
import { getCurrentAuthState } from '@core/auth/auth.selectors'; |
|||
import { Store } from '@ngrx/store'; |
|||
import { AppState } from '@core/core.state'; |
|||
import { forkJoin, Observable } from 'rxjs'; |
|||
import { NULL_UUID } from '@shared/models/id/has-uuid'; |
|||
import { BaseData } from '@shared/models/base-data'; |
|||
import { |
|||
CalculatedFieldGeofencingZoneGroupsPanelComponent |
|||
} from '@home/components/calculated-fields/components/panel/calculated-field-geofencing-zone-groups-panel.component'; |
|||
|
|||
@Component({ |
|||
selector: 'tb-calculated-field-geofencing-zone-groups-table', |
|||
templateUrl: './calculated-field-geofencing-zone-groups-table.component.html', |
|||
styleUrls: [`calculated-field-geofencing-zone-groups-table.component.scss`], |
|||
providers: [ |
|||
{ |
|||
provide: NG_VALUE_ACCESSOR, |
|||
useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), |
|||
multi: true |
|||
}, |
|||
{ |
|||
provide: NG_VALIDATORS, |
|||
useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), |
|||
multi: true |
|||
} |
|||
], |
|||
}) |
|||
export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { |
|||
|
|||
@Input() entityId: EntityId; |
|||
@Input() tenantId: string; |
|||
@Input() entityName: string; |
|||
|
|||
@ViewChild(MatSort, { static: true }) sort: MatSort; |
|||
|
|||
errorText = ''; |
|||
zoneGroupsFormArray = this.fb.array<CalculatedFieldGeofencingValue>([]); |
|||
entityNameMap = new Map<string, string>(); |
|||
sortOrder = { direction: 'asc', property: '' }; |
|||
dataSource = new CalculatedFieldZoneDatasource(); |
|||
|
|||
readonly GeofencingReportStrategyTranslations = GeofencingReportStrategyTranslations; |
|||
readonly entityTypeTranslations = entityTypeTranslations; |
|||
readonly ArgumentEntityType = ArgumentEntityType; |
|||
readonly maxArgumentsPerCF = getCurrentAuthState(this.store).maxArgumentsPerCF - 2; |
|||
readonly NULL_UUID = NULL_UUID; |
|||
|
|||
private popoverComponent: TbPopoverComponent<CalculatedFieldGeofencingZoneGroupsPanelComponent>; |
|||
private propagateChange: (zonesObj: Record<string, CalculatedFieldGeofencing>) => void = () => {}; |
|||
|
|||
constructor( |
|||
private fb: FormBuilder, |
|||
private popoverService: TbPopoverService, |
|||
private viewContainerRef: ViewContainerRef, |
|||
private cd: ChangeDetectorRef, |
|||
private renderer: Renderer2, |
|||
private entityService: EntityService, |
|||
private destroyRef: DestroyRef, |
|||
private store: Store<AppState> |
|||
) { |
|||
this.zoneGroupsFormArray.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { |
|||
this.updateDataSource(value); |
|||
this.propagateChange(this.getZonesObject(value)); |
|||
}); |
|||
} |
|||
|
|||
ngAfterViewInit(): void { |
|||
this.sort.sortChange.asObservable().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { |
|||
this.sortOrder.property = this.sort.active; |
|||
this.sortOrder.direction = this.sort.direction; |
|||
this.updateDataSource(this.zoneGroupsFormArray.value); |
|||
}); |
|||
} |
|||
|
|||
registerOnChange(fn: (zonesObj: Record<string, CalculatedFieldGeofencing>) => void): void { |
|||
this.propagateChange = fn; |
|||
} |
|||
|
|||
registerOnTouched(_): void {} |
|||
|
|||
validate(): ValidationErrors | null { |
|||
this.updateErrorText(); |
|||
return this.errorText ? { zonesFormArray: false } : null; |
|||
} |
|||
|
|||
onDelete($event: Event, zone: CalculatedFieldGeofencingValue): void { |
|||
$event.stopPropagation(); |
|||
const index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone)); |
|||
this.zoneGroupsFormArray.removeAt(index); |
|||
this.zoneGroupsFormArray.markAsDirty(); |
|||
} |
|||
|
|||
manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue): void { |
|||
$event?.stopPropagation(); |
|||
if (this.popoverComponent && !this.popoverComponent.tbHidden) { |
|||
this.popoverComponent.hide(); |
|||
} |
|||
const trigger = matButton._elementRef.nativeElement; |
|||
if (this.popoverService.hasPopover(trigger)) { |
|||
this.popoverService.hidePopover(trigger); |
|||
} else { |
|||
const index = this.zoneGroupsFormArray.controls.findIndex(control => isEqual(control.value, zone)); |
|||
const isExists = index !== -1; |
|||
const ctx = { |
|||
index, |
|||
zone, |
|||
entityId: this.entityId, |
|||
calculatedFieldType: CalculatedFieldType.GEOFENCING, |
|||
buttonTitle: isExists ? 'action.apply' : 'action.add', |
|||
tenantId: this.tenantId, |
|||
entityName: this.entityName, |
|||
usedNames: this.zoneGroupsFormArray.value.map(({ name }) => name).filter(name => name !== zone.name), |
|||
}; |
|||
this.popoverComponent = this.popoverService.displayPopover({ |
|||
trigger, |
|||
renderer: this.renderer, |
|||
componentType: CalculatedFieldGeofencingZoneGroupsPanelComponent, |
|||
hostView: this.viewContainerRef, |
|||
preferredPlacement: isExists ? ['left', 'leftTop', 'leftBottom'] : ['topRight', 'right', 'rightTop'], |
|||
context: ctx, |
|||
isModal: true |
|||
}); |
|||
this.popoverComponent.tbComponentRef.instance.geofencingDataApplied.subscribe(({ entityName, ...value }) => { |
|||
this.popoverComponent.hide(); |
|||
if (entityName) { |
|||
this.entityNameMap.set(value.refEntityId.id, entityName); |
|||
} |
|||
if (isExists) { |
|||
this.zoneGroupsFormArray.at(index).setValue(value); |
|||
} else { |
|||
this.zoneGroupsFormArray.push(this.fb.control(value)); |
|||
} |
|||
this.cd.markForCheck(); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
private updateDataSource(value: CalculatedFieldGeofencingValue[]): void { |
|||
const sortedValue = this.sortData(value); |
|||
this.dataSource.loadData(sortedValue); |
|||
} |
|||
|
|||
private updateErrorText(): void { |
|||
if (this.zoneGroupsFormArray.controls.some(control => control.value.refEntityId?.id === NULL_UUID)) { |
|||
this.errorText = 'calculated-fields.hint.geofencing-entity-not-found'; |
|||
} else if (!this.zoneGroupsFormArray.controls.length) { |
|||
this.errorText = 'calculated-fields.hint.geofencing-empty'; |
|||
} else { |
|||
this.errorText = ''; |
|||
} |
|||
} |
|||
|
|||
private getZonesObject(value: CalculatedFieldGeofencingValue[]): Record<string, CalculatedFieldGeofencing> { |
|||
return value.reduce((acc, zoneValue) => { |
|||
const { name, ...zone } = zoneValue as CalculatedFieldGeofencingValue; |
|||
acc[name] = zone; |
|||
return acc; |
|||
}, {} as Record<string, CalculatedFieldGeofencing>); |
|||
} |
|||
|
|||
writeValue(zonesObj: Record<string, CalculatedFieldGeofencing>): void { |
|||
this.zoneGroupsFormArray.clear(); |
|||
this.populateZonesFormArray(zonesObj); |
|||
this.updateEntityNameMap(this.zoneGroupsFormArray.value); |
|||
} |
|||
|
|||
getEntityDetailsPageURL(id: string, type: EntityType): string { |
|||
return getEntityDetailsPageURL(id, type); |
|||
} |
|||
|
|||
private populateZonesFormArray(zonesObj: Record<string, CalculatedFieldGeofencing>): void { |
|||
Object.keys(zonesObj).forEach(key => { |
|||
const value: CalculatedFieldGeofencingValue = { |
|||
...zonesObj[key], |
|||
name: key |
|||
}; |
|||
this.zoneGroupsFormArray.push(this.fb.control(value), { emitEvent: false }); |
|||
}); |
|||
this.zoneGroupsFormArray.updateValueAndValidity(); |
|||
} |
|||
|
|||
private updateEntityNameMap(values: CalculatedFieldGeofencingValue[]): void { |
|||
const entitiesByType = values.reduce((acc, { refEntityId = {}}) => { |
|||
if (refEntityId.id && refEntityId.entityType !== ArgumentEntityType.Tenant) { |
|||
const { id, entityType } = refEntityId as EntityId; |
|||
acc[entityType] = acc[entityType] ?? []; |
|||
acc[entityType].push(id); |
|||
} |
|||
return acc; |
|||
}, {} as Record<EntityType, string[]>); |
|||
const tasks = Object.entries(entitiesByType).map(([entityType, ids]) => |
|||
this.entityService.getEntities(entityType as EntityType, ids) |
|||
); |
|||
if (!tasks.length) { |
|||
return; |
|||
} |
|||
this.fetchEntityNames(tasks, values); |
|||
} |
|||
|
|||
private fetchEntityNames(tasks: Observable<BaseData<EntityId>[]>[], values: CalculatedFieldGeofencingValue[]): void { |
|||
forkJoin(tasks as Observable<BaseData<EntityId>[]>[]) |
|||
.pipe(takeUntilDestroyed(this.destroyRef)) |
|||
.subscribe((result: Array<BaseData<EntityId>>[]) => { |
|||
result.forEach((entities: BaseData<EntityId>[]) => entities.forEach((entity: BaseData<EntityId>) => this.entityNameMap.set(entity.id.id, entity.name))); |
|||
let updateTable = false; |
|||
values.forEach(({ refEntityId }) => { |
|||
if (refEntityId?.id && !this.entityNameMap.has(refEntityId.id) && refEntityId.entityType !== ArgumentEntityType.Tenant) { |
|||
updateTable = true; |
|||
const control = this.zoneGroupsFormArray.controls.find(control => control.value.refEntityId?.id === refEntityId.id); |
|||
const value = control.value; |
|||
value.refEntityId.id = NULL_UUID; |
|||
control.setValue(value, { emitEvent: false }); |
|||
} |
|||
}); |
|||
if (updateTable) { |
|||
this.zoneGroupsFormArray.updateValueAndValidity(); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
private getSortValue(zone: CalculatedFieldGeofencingValue, column: string): string { |
|||
switch (column) { |
|||
case 'entityType': |
|||
if (zone.refEntityId?.entityType === ArgumentEntityType.Tenant) { |
|||
return 'calculated-fields.argument-current-tenant'; |
|||
} else if (zone.refDynamicSourceConfiguration.type === ArgumentEntityType.RelationQuery) { |
|||
return 'calculated-fields.argument-relation-query'; |
|||
} else if (zone.refEntityId?.id) { |
|||
return entityTypeTranslations.get((zone.refEntityId)?.entityType as unknown as EntityType).type; |
|||
} else { |
|||
return 'calculated-fields.argument-current'; |
|||
} |
|||
case 'key': |
|||
return zone.perimeterKeyName; |
|||
case 'reportStrategy': |
|||
return GeofencingReportStrategyTranslations.get(zone.reportStrategy); |
|||
default: |
|||
return zone.name; |
|||
} |
|||
} |
|||
|
|||
private sortData(data: CalculatedFieldGeofencingValue[]): CalculatedFieldGeofencingValue[] { |
|||
return data.sort((a, b) => { |
|||
const valA = this.getSortValue(a, this.sortOrder.property) ?? ''; |
|||
const valB = this.getSortValue(b, this.sortOrder.property) ?? ''; |
|||
return (this.sortOrder.direction === 'asc' ? 1 : -1) * valA.localeCompare(valB); |
|||
}); |
|||
} |
|||
} |
|||
|
|||
class CalculatedFieldZoneDatasource extends TbTableDatasource<CalculatedFieldGeofencingValue> { |
|||
constructor() { |
|||
super(); |
|||
} |
|||
} |
|||
@ -0,0 +1,224 @@ |
|||
<!-- |
|||
|
|||
Copyright © 2016-2025 The Thingsboard Authors |
|||
|
|||
Licensed under the Apache License, Version 2.0 (the "License"); |
|||
you may not use this file except in compliance with the License. |
|||
You may obtain a copy of the License at |
|||
|
|||
http://www.apache.org/licenses/LICENSE-2.0 |
|||
|
|||
Unless required by applicable law or agreed to in writing, software |
|||
distributed under the License is distributed on an "AS IS" BASIS, |
|||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
See the License for the specific language governing permissions and |
|||
limitations under the License. |
|||
|
|||
--> |
|||
<div class="w-full max-w-xl" [formGroup]="geofencingFormGroup"> |
|||
<div class="tb-form-panel no-border no-padding mb-2"> |
|||
<div class="tb-form-panel-title">{{ 'calculated-fields.geofencing-zone-groups-settings' | translate }}</div> |
|||
<div class="tb-form-panel no-border no-padding"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.name' | translate }}</div> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput autocomplete="new-name" name="value" formControlName="name" maxlength="255" placeholder="{{ 'action.set' | translate }}"/> |
|||
@if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('required')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-required' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('duplicateName')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-duplicate' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('pattern')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-pattern' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('maxlength')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-max-length' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (geofencingFormGroup.get('name').touched && geofencingFormGroup.get('name').hasError('forbiddenName')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.name-forbidden' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} |
|||
</mat-form-field> |
|||
</div> |
|||
<ng-container [formGroup]="refEntityIdFormGroup"> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'entity.entity-type' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="entityType"> |
|||
@for (type of argumentEntityTypes; track type) { |
|||
<mat-option [value]="type">{{ ArgumentEntityTypeTranslations.get(type) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
@if (ArgumentEntityTypeParamsMap.has(entityType)) { |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ ArgumentEntityTypeParamsMap.get(entityType).title | translate }}</div> |
|||
<tb-entity-autocomplete class="flex flex-1" |
|||
#entityAutocomplete |
|||
formControlName="id" |
|||
inlineField |
|||
[placeholder]="'action.set' | translate" |
|||
[required]="true" |
|||
[entityType]="ArgumentEntityTypeParamsMap.get(entityType).entityType" |
|||
(entityChanged)="entityNameSubject.next($event?.name)"/> |
|||
</div> |
|||
} |
|||
</ng-container> |
|||
<ng-container [formGroup]="refDynamicSourceFormGroup"> |
|||
<div class="tb-form-panel stroked" *ngIf="entityType === ArgumentEntityType.RelationQuery"> |
|||
<mat-expansion-panel class="tb-settings" expanded> |
|||
<mat-expansion-panel-header>{{ 'calculated-fields.relation-query' | translate }}*</mat-expansion-panel-header> |
|||
|
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="direction"> |
|||
@for (direction of GeofencingDirectionList; track direction) { |
|||
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div> |
|||
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)" |
|||
additionalClass="tb-suffix-show-on-hover" |
|||
class="flex-1" |
|||
appearance="outline" |
|||
panelWidth="" |
|||
required |
|||
[errorText]="'calculated-fields.hint.relation-type-required' | translate" |
|||
formControlName="relationType"> |
|||
</tb-string-autocomplete> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-level' | translate }}</div> |
|||
<mat-form-field class="flex-1" appearance="outline" subscriptSizing="dynamic"> |
|||
<input matInput type="number" step="1" min="0" formControlName="maxLevel" placeholder="{{ 'action.set' | translate }}"/> |
|||
@if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('required')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.relation-level-required' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('min')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.relation-level-min' | translate" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} @else if (refDynamicSourceFormGroup.get('maxLevel').touched && refDynamicSourceFormGroup.get('maxLevel').hasError('max')) { |
|||
<mat-icon matSuffix |
|||
matTooltipPosition="above" |
|||
matTooltipClass="tb-error-tooltip" |
|||
[matTooltip]="'calculated-fields.hint.relation-level-max' | translate: {max: maxRelationLevelPerCfArgument}" |
|||
class="tb-error"> |
|||
warning |
|||
</mat-icon> |
|||
} |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row" [class.!hidden]="!(this.refDynamicSourceFormGroup.get('maxLevel').value > 1)"> |
|||
<mat-slide-toggle class="mat-slide margin" formControlName="fetchLastLevelOnly"> |
|||
{{ 'calculated-fields.fetch-last-available-level' | translate }} |
|||
</mat-slide-toggle> |
|||
</div> |
|||
</mat-expansion-panel> |
|||
</div> |
|||
</ng-container> |
|||
<ng-container> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width tb-required" tb-hint-tooltip-icon="{{'calculated-fields.hint.perimeter-attribute-key' | translate}}"> |
|||
{{ 'calculated-fields.perimeter-attribute-key' | translate }} |
|||
</div> |
|||
<tb-entity-key-autocomplete class="flex-1" formControlName="perimeterKeyName" [dataKeyType]="DataKeyType.attribute" [entityFilter]="entityFilter"/> |
|||
</div> |
|||
<div class="tb-form-row"> |
|||
<div class="fixed-title-width" tb-hint-tooltip-icon="{{'calculated-fields.hint.report-strategy' | translate}}">{{ 'calculated-fields.report-strategy' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="reportStrategy"> |
|||
@for (strategy of GeofencingReportStrategyList; track strategy) { |
|||
<mat-option [value]="strategy">{{ GeofencingReportStrategyTranslations.get(strategy) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
</ng-container> |
|||
<div class="tb-form-panel stroked"> |
|||
<mat-slide-toggle class="mat-slide" formControlName="createRelationsWithMatchedZones" (click)="$event.stopPropagation()"> |
|||
<div tb-hint-tooltip-icon="{{ 'calculated-fields.hint.create-relation-with-matched-zones' | translate }}"> |
|||
{{ 'calculated-fields.create-relation-with-matched-zones' | translate }} |
|||
</div> |
|||
</mat-slide-toggle> |
|||
<div class="tb-form-row" [class.!hidden]="!geofencingFormGroup.get('createRelationsWithMatchedZones').value"> |
|||
<div class="fixed-title-width">{{ 'calculated-fields.direction' | translate }}</div> |
|||
<mat-form-field class="tb-flex no-gap" appearance="outline" subscriptSizing="dynamic"> |
|||
<mat-select formControlName="direction"> |
|||
@for (direction of GeofencingDirectionList; track direction) { |
|||
<mat-option [value]="direction">{{ GeofencingDirectionTranslations.get(direction) | translate }}</mat-option> |
|||
} |
|||
</mat-select> |
|||
</mat-form-field> |
|||
</div> |
|||
<div class="tb-form-row" [class.!hidden]="!geofencingFormGroup.get('createRelationsWithMatchedZones').value"> |
|||
<div class="fixed-title-width tb-required">{{ 'calculated-fields.relation-type' | translate }}</div> |
|||
<tb-string-autocomplete [fetchOptionsFn]="fetchOptions.bind(this)" |
|||
additionalClass="tb-suffix-show-on-hover" |
|||
class="flex-1" |
|||
appearance="outline" |
|||
panelWidth="" |
|||
required |
|||
[errorText]="'calculated-fields.hint.relation-type-required' | translate" |
|||
formControlName="relationType"> |
|||
</tb-string-autocomplete> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="flex justify-end gap-2"> |
|||
<button mat-button |
|||
color="primary" |
|||
type="button" |
|||
(click)="cancel()"> |
|||
{{ 'action.cancel' | translate }} |
|||
</button> |
|||
<button mat-raised-button |
|||
color="primary" |
|||
type="button" |
|||
(click)="saveZone()" |
|||
[disabled]="geofencingFormGroup.invalid || !geofencingFormGroup.dirty"> |
|||
{{ buttonTitle | translate }} |
|||
</button> |
|||
</div> |
|||
</div> |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue