committed by
GitHub
493 changed files with 19248 additions and 5294 deletions
@ -1,43 +1,150 @@ |
|||
# ThingsBoard |
|||
[](https://builds.thingsboard.io/viewType.html?buildTypeId=ThingsBoard_Build&guest=1) |
|||
 |
|||
|
|||
<div align="center"> |
|||
|
|||
# Open-source IoT platform for data collection, processing, visualization, and device management. |
|||
|
|||
</div> |
|||
<br> |
|||
<div align="center"> |
|||
|
|||
💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • ▶️ [Live demo](https://demo.thingsboard.io/signup) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all) |
|||
|
|||
</div> |
|||
|
|||
## 🚀 Installation options |
|||
|
|||
* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) |
|||
* Try [ThingsBoard Cloud](https://thingsboard.io/installations/) |
|||
* or [Use our Live demo](https://demo.thingsboard.io/signup) |
|||
|
|||
## 💡 Getting started with ThingsBoard |
|||
|
|||
Check out our [Getting Started guide](https://thingsboard.io/docs/getting-started-guides/helloworld/) or [watch the video](https://www.youtube.com/watch?v=80L0ubQLXsc) to learn the basics of ThingsBoard and create your first dashboard! You will learn to: |
|||
|
|||
* Connect devices to ThingsBoard |
|||
* Push data from devices to ThingsBoard |
|||
* Build real-time dashboards |
|||
* Create a Customer and assign the dashboard with them. |
|||
* Define thresholds and trigger alarms |
|||
* Set up notifications via email, SMS, mobile apps, or integrate with third-party services. |
|||
|
|||
## ✨ Features |
|||
|
|||
<table> |
|||
<tr> |
|||
<td width="50%" valign="top"> |
|||
<br> |
|||
<div align="center"> |
|||
<img src="https://github.com/user-attachments/assets/255cca4f-b111-44e8-99ea-0af55f8e3681" alt="Provision and manage devices and assets" width="378" /> |
|||
<h3>Provision and manage <br> devices and assets</h3> |
|||
</div> |
|||
<div align="center"> |
|||
<p>Provision, monitor and control your IoT entities in secure way using rich server-side APIs. Define relations between your devices, assets, customers or any other entities.</p> |
|||
</div> |
|||
<br> |
|||
<div align="center"> |
|||
<a href="https://thingsboard.io/docs/user-guide/entities-and-relations/">Read more ➜</a> |
|||
</div> |
|||
<br> |
|||
</td> |
|||
<td width="50%" valign="top"> |
|||
<br> |
|||
<div align="center"> |
|||
<img src="https://github.com/user-attachments/assets/24b41d10-150a-42dd-ab1a-32ac9b5978c1" alt="Collect and visualize your data" width="378" /> |
|||
<h3>Collect and visualize <br> your data</h3> |
|||
</div> |
|||
<div align="center"> |
|||
<p>Collect and store telemetry data in scalable and fault-tolerant way. Visualize your data with built-in or custom widgets and flexible dashboards. Share dashboards with your customers.</p> |
|||
</div> |
|||
<br> |
|||
<div align="center"> |
|||
<a href="https://thingsboard.io/iot-data-visualization/">Read more ➜</a> |
|||
</div> |
|||
<br> |
|||
</td> |
|||
</tr> |
|||
<tr> |
|||
<td width="50%" valign="top"> |
|||
<br> |
|||
<div align="center"> |
|||
<img src="https://github.com/user-attachments/assets/6f2a6dd2-7b33-4d17-8b92-d1f995adda2c" alt="SCADA Dashboards" width="378" /> |
|||
<h3>SCADA Dashboards</h3> |
|||
</div> |
|||
<div align="center"> |
|||
<p>Monitor and control your industrial processes in real time with SCADA. Use SCADA symbols on dashboards to create and manage any workflow, offering full flexibility to design and oversee operations according to your requirements.</p> |
|||
</div> |
|||
<br> |
|||
<div align="center"> |
|||
<a href="https://thingsboard.io/use-cases/scada/">Read more ➜</a> |
|||
</div> |
|||
<br> |
|||
</td> |
|||
<td width="50%" valign="top"> |
|||
<br> |
|||
<div align="center"> |
|||
<img src="https://github.com/user-attachments/assets/c23dcc9b-aeba-40ef-9973-49b953fc1257" alt="Process and React" width="378" /> |
|||
<h3>Process and React</h3> |
|||
</div> |
|||
<div align="center"> |
|||
<p>Define data processing rule chains. Transform and normalize your device data. Raise alarms on incoming telemetry events, attribute updates, device inactivity and user actions.<br></p> |
|||
</div> |
|||
<br> |
|||
<br> |
|||
<div align="center"> |
|||
<a href="https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/">Read more ➜</a> |
|||
</div> |
|||
<br> |
|||
</td> |
|||
</tr> |
|||
</table> |
|||
|
|||
## ⚙️ Powerful IoT Rule Engine |
|||
|
|||
ThingsBoard allows you to create complex [Rule Chains](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) to process data from your devices and match your application specific use cases. |
|||
|
|||
[](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) |
|||
|
|||
<div align="center"> |
|||
|
|||
[**Read more about Rule Engine ➜**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) |
|||
|
|||
</div> |
|||
|
|||
## 📦 Real-Time IoT Dashboards |
|||
|
|||
ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that speeds up time-to-market with powerful built-in solution templates. It enables data collection and analysis from any devices, saving resources on routine tasks and letting you focus on your solution’s unique aspects. See more our Use Cases [here](https://thingsboard.io/iot-use-cases/). |
|||
|
|||
[**Smart energy**](https://thingsboard.io/use-cases/smart-energy/) |
|||
|
|||
[](https://thingsboard.io/use-cases/smart-energy/) |
|||
|
|||
[**SCADA swimming pool**](https://thingsboard.io/use-cases/scada/) |
|||
|
|||
[](https://thingsboard.io/use-cases/scada/) |
|||
|
|||
[**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/) |
|||
|
|||
[](https://thingsboard.io/use-cases/fleet-tracking/) |
|||
|
|||
[**Smart farming**](https://thingsboard.io/use-cases/smart-farming/) |
|||
|
|||
[](https://thingsboard.io/use-cases/smart-farming/) |
|||
|
|||
ThingsBoard is an open-source IoT platform for data collection, processing, visualization, and device management. |
|||
|
|||
<img src="./img/logo.png?raw=true" width="100" height="100"> |
|||
|
|||
|
|||
## Documentation |
|||
|
|||
ThingsBoard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs). |
|||
|
|||
## IoT use cases |
|||
|
|||
[**Smart energy**](https://thingsboard.io/smart-energy/) |
|||
[](https://thingsboard.io/smart-energy/) |
|||
|
|||
[**SCADA Swimming pool**](https://thingsboard.io/use-cases/scada/) |
|||
[](https://thingsboard.io/use-cases/scada/) |
|||
|
|||
[**Fleet tracking**](https://thingsboard.io/fleet-tracking/) |
|||
[](https://thingsboard.io/fleet-tracking/) |
|||
|
|||
[**Smart farming**](https://thingsboard.io/smart-farming/) |
|||
[](https://thingsboard.io/smart-farming/) |
|||
[**Smart metering**](https://thingsboard.io/smart-metering/) |
|||
|
|||
[**IoT Rule Engine**](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) |
|||
[](https://thingsboard.io/docs/user-guide/rule-engine-2-0/re-getting-started/) |
|||
[](https://thingsboard.io/smart-metering/) |
|||
|
|||
[**Smart metering**](https://thingsboard.io/smart-metering/) |
|||
[](https://thingsboard.io/smart-metering/) |
|||
<div align="center"> |
|||
|
|||
## Getting Started |
|||
[**Check more of our use cases ➜**](https://thingsboard.io/iot-use-cases/) |
|||
|
|||
Collect and Visualize your IoT data in minutes by following this [guide](https://thingsboard.io/docs/getting-started-guides/helloworld/). |
|||
</div> |
|||
|
|||
## Support |
|||
## 🫶 Support |
|||
|
|||
- [Stackoverflow](http://stackoverflow.com/questions/tagged/thingsboard) |
|||
To get support, please visit our [GitHub issues page](https://github.com/thingsboard/thingsboard/issues) |
|||
|
|||
## Licenses |
|||
## 📄 Licenses |
|||
|
|||
This project is released under [Apache 2.0 License](./LICENSE). |
|||
This project is released under [Apache 2.0 License](./LICENSE) |
|||
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,252 @@ |
|||
/** |
|||
* 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.RelationPathQueryDynamicSourceConfiguration; |
|||
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 org.thingsboard.server.service.cf.ctx.state.geofencing.GeofencingCalculatedFieldState; |
|||
|
|||
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)); |
|||
// TODO: move to state.init() method after merge with alarm rules 2.0
|
|||
if (ctx.hasRelationQueryDynamicArguments() && result instanceof GeofencingCalculatedFieldState geofencingCalculatedFieldState) { |
|||
geofencingCalculatedFieldState.setLastDynamicArgumentsRefreshTs(System.currentTimeMillis()); |
|||
} |
|||
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_PATH_QUERY -> { |
|||
var configuration = (RelationPathQueryDynamicSourceConfiguration) refDynamicSourceConfiguration; |
|||
yield Futures.transform(relationService.findByRelationPathQueryAsync(tenantId, configuration.toRelationPathQuery(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,111 @@ |
|||
/** |
|||
* 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(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()); |
|||
} |
|||
if (geofencingArgumentEntry.isEmpty()) { |
|||
zoneStates.clear(); |
|||
return true; |
|||
} |
|||
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; |
|||
} |
|||
if (newZoneState.getPerimeterDefinition() == null) { |
|||
zoneStates.remove(zoneId); |
|||
return true; |
|||
} |
|||
return existingZoneState.update(newZoneState); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,188 @@ |
|||
/** |
|||
* 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.JsonNode; |
|||
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.NoArgsConstructor; |
|||
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.OutputType; |
|||
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.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 |
|||
@NoArgsConstructor |
|||
@EqualsAndHashCode(callSuper = true) |
|||
public class GeofencingCalculatedFieldState extends BaseCalculatedFieldState { |
|||
|
|||
private long lastDynamicArgumentsRefreshTs = -1; |
|||
|
|||
public GeofencingCalculatedFieldState(List<String> requiredArguments) { |
|||
super(requiredArguments); |
|||
} |
|||
|
|||
@Override |
|||
public CalculatedFieldType getType() { |
|||
return CalculatedFieldType.GEOFENCING; |
|||
} |
|||
|
|||
@Override |
|||
protected void validateNewEntry(String key, ArgumentEntry newEntry) { |
|||
switch (key) { |
|||
case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> { |
|||
if (!(newEntry instanceof SingleValueArgumentEntry)) { |
|||
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + |
|||
"Only SINGLE_VALUE type is allowed."); |
|||
} |
|||
} |
|||
default -> { |
|||
if (!(newEntry instanceof GeofencingArgumentEntry)) { |
|||
throw new IllegalArgumentException("Unsupported argument entry type for " + key + " argument: " + newEntry.getType() + ". " + |
|||
"Only GEOFENCING type is allowed."); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
@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 valuesNode = 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); |
|||
} |
|||
}); |
|||
updateValuesNode(argumentKey, zoneResults, zoneGroupCfg.getReportStrategy(), valuesNode); |
|||
}); |
|||
|
|||
OutputType outputType = ctx.getOutput().getType(); |
|||
var result = new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), toResultNode(outputType, valuesNode)); |
|||
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 updateValuesNode(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 JsonNode toResultNode(OutputType outputType, ObjectNode valuesNode) { |
|||
if (OutputType.ATTRIBUTES.equals(outputType) || latestTimestamp == -1) { |
|||
return valuesNode; |
|||
} |
|||
ObjectNode resultNode = JacksonUtil.newObjectNode(); |
|||
resultNode.put("ts", latestTimestamp); |
|||
resultNode.set("values", valuesNode); |
|||
return resultNode; |
|||
} |
|||
|
|||
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,478 @@ |
|||
/** |
|||
* 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.RelationPathQueryDynamicSourceConfiguration; |
|||
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.common.data.relation.RelationPathLevel; |
|||
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().get("values")).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().get("values")).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().get("values")).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 RelationPathQueryDynamicSourceConfiguration(); |
|||
allowedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "AllowedZone"))); |
|||
allowedZonesGroup.setRefDynamicSourceConfiguration(allowedZoneDynamicSourceConfiguration); |
|||
allowedZonesGroup.setRelationType("CurrentZone"); |
|||
allowedZonesGroup.setDirection(EntitySearchDirection.TO); |
|||
|
|||
ZoneGroupConfiguration restrictedZonesGroup = new ZoneGroupConfiguration("zone", reportStrategy, true); |
|||
var restrictedZoneDynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); |
|||
restrictedZoneDynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.TO, "RestrictedZone"))); |
|||
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(); |
|||
} |
|||
|
|||
} |
|||
@ -1,95 +0,0 @@ |
|||
/** |
|||
* 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.install.update; |
|||
|
|||
import com.fasterxml.jackson.core.JsonProcessingException; |
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
|
|||
import java.io.IOException; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.BDDMockito.willCallRealMethod; |
|||
|
|||
@ActiveProfiles("install") |
|||
@SpringBootTest(classes = DefaultDataUpdateService.class) |
|||
class DefaultDataUpdateServiceTest { |
|||
|
|||
@MockBean |
|||
DefaultDataUpdateService service; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
willCallRealMethod().given(service).convertDeviceProfileAlarmRulesForVersion330(any()); |
|||
willCallRealMethod().given(service).convertDeviceProfileForVersion330(any()); |
|||
} |
|||
|
|||
JsonNode readFromResource(String resourceName) throws IOException { |
|||
return JacksonUtil.OBJECT_MAPPER.readTree(this.getClass().getClassLoader().getResourceAsStream(resourceName)); |
|||
} |
|||
|
|||
@Test |
|||
void convertDeviceProfileAlarmRulesForVersion330FirstRun() throws IOException { |
|||
JsonNode spec = readFromResource("update/330/device_profile_001_in.json"); |
|||
JsonNode expected = readFromResource("update/330/device_profile_001_out.json"); |
|||
|
|||
assertThat(service.convertDeviceProfileForVersion330(spec.get("profileData"))).isTrue(); |
|||
assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); // use IDE feature <Click to see difference>
|
|||
} |
|||
|
|||
@Test |
|||
void convertDeviceProfileAlarmRulesForVersion330SecondRun() throws IOException { |
|||
JsonNode spec = readFromResource("update/330/device_profile_001_out.json"); |
|||
JsonNode expected = readFromResource("update/330/device_profile_001_out.json"); |
|||
|
|||
assertThat(service.convertDeviceProfileForVersion330(spec.get("profileData"))).isFalse(); |
|||
assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); // use IDE feature <Click to see difference>
|
|||
} |
|||
|
|||
@Test |
|||
void convertDeviceProfileAlarmRulesForVersion330EmptyJson() throws JsonProcessingException { |
|||
JsonNode spec = JacksonUtil.toJsonNode("{ }"); |
|||
JsonNode expected = JacksonUtil.toJsonNode("{ }"); |
|||
|
|||
assertThat(service.convertDeviceProfileForVersion330(spec)).isFalse(); |
|||
assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); |
|||
} |
|||
|
|||
@Test |
|||
void convertDeviceProfileAlarmRulesForVersion330AlarmNodeNull() throws JsonProcessingException { |
|||
JsonNode spec = JacksonUtil.toJsonNode("{ \"alarms\" : null }"); |
|||
JsonNode expected = JacksonUtil.toJsonNode("{ \"alarms\" : null }"); |
|||
|
|||
assertThat(service.convertDeviceProfileForVersion330(spec)).isFalse(); |
|||
assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); |
|||
} |
|||
|
|||
@Test |
|||
void convertDeviceProfileAlarmRulesForVersion330NoAlarmNode() throws JsonProcessingException { |
|||
JsonNode spec = JacksonUtil.toJsonNode("{ \"configuration\": { \"type\": \"DEFAULT\" } }"); |
|||
JsonNode expected = JacksonUtil.toJsonNode("{ \"configuration\": { \"type\": \"DEFAULT\" } }"); |
|||
|
|||
assertThat(service.convertDeviceProfileForVersion330(spec)).isFalse(); |
|||
assertThat(spec.toPrettyString()).isEqualTo(expected.toPrettyString()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,83 @@ |
|||
/** |
|||
* 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.resource; |
|||
|
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.GeneralFileDescriptor; |
|||
import org.thingsboard.server.common.data.ResourceType; |
|||
import org.thingsboard.server.common.data.TbResource; |
|||
import org.thingsboard.server.common.data.TbResourceDataInfo; |
|||
import org.thingsboard.server.common.data.TbResourceInfo; |
|||
import org.thingsboard.server.controller.AbstractControllerTest; |
|||
import org.thingsboard.server.dao.resource.ResourceService; |
|||
import org.thingsboard.server.dao.resource.TbResourceDataCache; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.Mockito.clearInvocations; |
|||
import static org.mockito.Mockito.timeout; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.verifyNoMoreInteractions; |
|||
|
|||
@DaoSqlTest |
|||
public class DefaultResourceDataCacheTest extends AbstractControllerTest { |
|||
|
|||
@MockitoSpyBean |
|||
private ResourceService resourceService; |
|||
@Autowired |
|||
private TbResourceService tbResourceService; |
|||
@MockitoSpyBean |
|||
private TbResourceDataCache resourceDataCache; |
|||
|
|||
@Test |
|||
public void testGetCachedResourceData() throws Exception { |
|||
loginTenantAdmin(); |
|||
|
|||
TbResource resource = new TbResource(); |
|||
resource.setTenantId(tenantId); |
|||
resource.setTitle("File for AI request"); |
|||
resource.setResourceType(ResourceType.GENERAL); |
|||
resource.setFileName("myTestJson.json"); |
|||
GeneralFileDescriptor descriptor = new GeneralFileDescriptor("application/json"); |
|||
resource.setDescriptorValue(descriptor); |
|||
byte[] data = "This is a test prompt for AI request.".getBytes(); |
|||
resource.setData(data); |
|||
TbResourceInfo savedResource = tbResourceService.save(resource); |
|||
verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); |
|||
|
|||
TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); |
|||
assertThat(cachedData.getData()).isEqualTo(data); |
|||
assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); |
|||
verify(resourceService).getResourceDataInfo(tenantId, savedResource.getId()); |
|||
|
|||
// retrieve resource data second time
|
|||
clearInvocations(resourceService); |
|||
TbResourceDataInfo cachedData2 = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); |
|||
assertThat(cachedData2.getData()).isEqualTo(data); |
|||
verifyNoMoreInteractions(resourceService); |
|||
|
|||
// delete resource, check cache
|
|||
TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); |
|||
tbResourceService.delete(resourceById, true, null); |
|||
verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); |
|||
TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); |
|||
assertThat(cachedDataAfterDeletion).isEqualTo(null); |
|||
} |
|||
|
|||
} |
|||
@ -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,28 @@ |
|||
/** |
|||
* 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.dao.resource; |
|||
|
|||
import com.google.common.util.concurrent.FluentFuture; |
|||
import org.thingsboard.server.common.data.TbResourceDataInfo; |
|||
import org.thingsboard.server.common.data.id.TbResourceId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
|
|||
public interface TbResourceDataCache { |
|||
|
|||
FluentFuture<TbResourceDataInfo> getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId); |
|||
|
|||
void evictResourceData(TenantId tenantId, TbResourceId resourceId); |
|||
} |
|||
@ -0,0 +1,29 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import lombok.EqualsAndHashCode; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@EqualsAndHashCode |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
public class GeneralFileDescriptor { |
|||
private String mediaType; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
/** |
|||
* 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; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import lombok.AllArgsConstructor; |
|||
import lombok.Data; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@AllArgsConstructor |
|||
@NoArgsConstructor |
|||
public class TbResourceDataInfo { |
|||
|
|||
private byte[] data; |
|||
private JsonNode descriptor; |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue