472 changed files with 19580 additions and 4825 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,130 @@ |
|||
/** |
|||
* 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.edge.rpc.processor.ai; |
|||
|
|||
import com.google.common.util.concurrent.Futures; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.data.util.Pair; |
|||
import org.springframework.stereotype.Component; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.EdgeUtils; |
|||
import org.thingsboard.server.common.data.ai.AiModel; |
|||
import org.thingsboard.server.common.data.edge.Edge; |
|||
import org.thingsboard.server.common.data.edge.EdgeEvent; |
|||
import org.thingsboard.server.common.data.edge.EdgeEventActionType; |
|||
import org.thingsboard.server.common.data.edge.EdgeEventType; |
|||
import org.thingsboard.server.common.data.id.AiModelId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.msg.TbMsgType; |
|||
import org.thingsboard.server.common.msg.TbMsgMetaData; |
|||
import org.thingsboard.server.dao.exception.DataValidationException; |
|||
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; |
|||
import org.thingsboard.server.gen.edge.v1.DownlinkMsg; |
|||
import org.thingsboard.server.gen.edge.v1.EdgeVersion; |
|||
import org.thingsboard.server.gen.edge.v1.UpdateMsgType; |
|||
import org.thingsboard.server.queue.util.TbCoreComponent; |
|||
import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.UUID; |
|||
|
|||
@Slf4j |
|||
@Component |
|||
@TbCoreComponent |
|||
public class AiModelEdgeProcessor extends BaseAiModelProcessor implements AiModelProcessor { |
|||
|
|||
@Override |
|||
public ListenableFuture<Void> processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg) { |
|||
AiModelId aiModelId = new AiModelId(new UUID(aiModelUpdateMsg.getIdMSB(), aiModelUpdateMsg.getIdLSB())); |
|||
try { |
|||
edgeSynchronizationManager.getEdgeId().set(edge.getId()); |
|||
|
|||
switch (aiModelUpdateMsg.getMsgType()) { |
|||
case ENTITY_CREATED_RPC_MESSAGE: |
|||
case ENTITY_UPDATED_RPC_MESSAGE: |
|||
processAiModel(tenantId, aiModelId, aiModelUpdateMsg, edge); |
|||
return Futures.immediateFuture(null); |
|||
case UNRECOGNIZED: |
|||
default: |
|||
return handleUnsupportedMsgType(aiModelUpdateMsg.getMsgType()); |
|||
} |
|||
} catch (DataValidationException e) { |
|||
return Futures.immediateFailedFuture(e); |
|||
} finally { |
|||
edgeSynchronizationManager.getEdgeId().remove(); |
|||
} |
|||
} |
|||
|
|||
@Override |
|||
public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { |
|||
AiModelId aiModelId = new AiModelId(edgeEvent.getEntityId()); |
|||
switch (edgeEvent.getAction()) { |
|||
case ADDED, UPDATED -> { |
|||
Optional<AiModel> aiModel = edgeCtx.getAiModelService().findAiModelById(edgeEvent.getTenantId(), aiModelId); |
|||
if (aiModel.isPresent()) { |
|||
UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); |
|||
AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelUpdatedMsg(msgType, aiModel.get()); |
|||
return DownlinkMsg.newBuilder() |
|||
.setDownlinkMsgId(EdgeUtils.nextPositiveInt()) |
|||
.addAiModelUpdateMsg(aiModelUpdateMsg) |
|||
.build(); |
|||
} |
|||
} |
|||
case DELETED -> { |
|||
AiModelUpdateMsg aiModelUpdateMsg = EdgeMsgConstructorUtils.constructAiModelDeleteMsg(aiModelId); |
|||
return DownlinkMsg.newBuilder() |
|||
.setDownlinkMsgId(EdgeUtils.nextPositiveInt()) |
|||
.addAiModelUpdateMsg(aiModelUpdateMsg) |
|||
.build(); |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
@Override |
|||
public EdgeEventType getEdgeEventType() { |
|||
return EdgeEventType.AI_MODEL; |
|||
} |
|||
|
|||
private void processAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg, Edge edge) { |
|||
Pair<Boolean, Boolean> resultPair = super.saveOrUpdateAiModel(tenantId, aiModelId, aiModelUpdateMsg); |
|||
Boolean wasCreated = resultPair.getFirst(); |
|||
if (wasCreated) { |
|||
pushAiModelCreatedEventToRuleEngine(tenantId, edge, aiModelId); |
|||
} |
|||
Boolean nameWasUpdated = resultPair.getSecond(); |
|||
if (nameWasUpdated) { |
|||
saveEdgeEvent(tenantId, edge.getId(), EdgeEventType.AI_MODEL, EdgeEventActionType.UPDATED, aiModelId, null); |
|||
} |
|||
} |
|||
|
|||
private void pushAiModelCreatedEventToRuleEngine(TenantId tenantId, Edge edge, AiModelId aiModelId) { |
|||
try { |
|||
Optional<AiModel> aiModel = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); |
|||
if (aiModel.isPresent()) { |
|||
String aiModelAsString = JacksonUtil.toString(aiModel.get()); |
|||
TbMsgMetaData msgMetaData = getEdgeActionTbMsgMetaData(edge, edge.getCustomerId()); |
|||
pushEntityEventToRuleEngine(tenantId, aiModelId, edge.getCustomerId(), TbMsgType.ENTITY_CREATED, aiModelAsString, msgMetaData); |
|||
} else { |
|||
log.warn("[{}][{}] Failed to find aiModel", tenantId, aiModelId); |
|||
} |
|||
} catch (Exception e) { |
|||
log.warn("[{}][{}] Failed to push aiModel action to rule engine: {}", tenantId, aiModelId, TbMsgType.ENTITY_CREATED.name(), e); |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -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.service.edge.rpc.processor.ai; |
|||
|
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import org.thingsboard.server.common.data.edge.Edge; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; |
|||
|
|||
public interface AiModelProcessor extends EdgeProcessor { |
|||
|
|||
ListenableFuture<Void> processAiModelMsgFromEdge(TenantId tenantId, Edge edge, AiModelUpdateMsg aiModelUpdateMsg); |
|||
|
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
/** |
|||
* Copyright © 2016-2025 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.service.edge.rpc.processor.ai; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.data.util.Pair; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.StringUtils; |
|||
import org.thingsboard.server.common.data.ai.AiModel; |
|||
import org.thingsboard.server.common.data.id.AiModelId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.dao.service.DataValidator; |
|||
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; |
|||
import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
@Slf4j |
|||
public abstract class BaseAiModelProcessor extends BaseEdgeProcessor { |
|||
|
|||
@Autowired |
|||
private DataValidator<AiModel> aiModelValidator; |
|||
|
|||
protected Pair<Boolean, Boolean> saveOrUpdateAiModel(TenantId tenantId, AiModelId aiModelId, AiModelUpdateMsg aiModelUpdateMsg) { |
|||
boolean isCreated = false; |
|||
boolean isNameUpdated = false; |
|||
try { |
|||
AiModel aiModel = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); |
|||
if (aiModel == null) { |
|||
throw new RuntimeException("[{" + tenantId + "}] aiModelUpdateMsg {" + aiModelUpdateMsg + " } cannot be converted to aiModel"); |
|||
} |
|||
|
|||
Optional<AiModel> aiModelById = edgeCtx.getAiModelService().findAiModelById(tenantId, aiModelId); |
|||
if (aiModelById.isEmpty()) { |
|||
aiModel.setCreatedTime(Uuids.unixTimestamp(aiModelId.getId())); |
|||
isCreated = true; |
|||
aiModel.setId(null); |
|||
} else { |
|||
aiModel.setId(aiModelId); |
|||
} |
|||
|
|||
String aiModelName = aiModel.getName(); |
|||
Optional<AiModel> aiModelByName = edgeCtx.getAiModelService().findAiModelByTenantIdAndName(aiModel.getTenantId(), aiModelName); |
|||
if (aiModelByName.isPresent() && !aiModelByName.get().getId().equals(aiModelId)) { |
|||
aiModelName = aiModelName + "_" + StringUtils.randomAlphabetic(15); |
|||
log.warn("[{}] aiModel with name {} already exists. Renaming aiModel name to {}", |
|||
tenantId, aiModel.getName(), aiModelByName.get().getName()); |
|||
isNameUpdated = true; |
|||
} |
|||
aiModel.setName(aiModelName); |
|||
|
|||
aiModelValidator.validate(aiModel, AiModel::getTenantId); |
|||
|
|||
if (isCreated) { |
|||
aiModel.setId(aiModelId); |
|||
} |
|||
|
|||
edgeCtx.getAiModelService().save(aiModel, false); |
|||
} catch (Exception e) { |
|||
log.error("[{}] Failed to process aiModel update msg [{}]", tenantId, aiModelUpdateMsg, e); |
|||
throw e; |
|||
} |
|||
return Pair.of(isCreated, isNameUpdated); |
|||
} |
|||
|
|||
} |
|||
@ -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,196 @@ |
|||
/** |
|||
* 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.edge; |
|||
|
|||
import com.datastax.oss.driver.api.core.uuid.Uuids; |
|||
import com.google.protobuf.AbstractMessage; |
|||
import com.google.protobuf.InvalidProtocolBufferException; |
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.ai.AiModel; |
|||
import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; |
|||
import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; |
|||
import org.thingsboard.server.gen.edge.v1.UpdateMsgType; |
|||
import org.thingsboard.server.gen.edge.v1.UplinkMsg; |
|||
import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; |
|||
|
|||
import java.util.Optional; |
|||
import java.util.UUID; |
|||
|
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@DaoSqlTest |
|||
public class AiModelEdgeTest extends AbstractEdgeTest { |
|||
|
|||
private static final String DEFAULT_AI_MODEL_NAME = "Edge Test AiModel"; |
|||
private static final String UPDATED_AI_MODEL_NAME = "Updated Edge Test AiModel"; |
|||
|
|||
@Test |
|||
public void testAiModel_create_update_delete() throws Exception { |
|||
// create AiModel
|
|||
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
AbstractMessage latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); |
|||
AiModelUpdateMsg aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); |
|||
Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); |
|||
Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); |
|||
AiModel aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); |
|||
Assert.assertNotNull(aiModelFromMsg); |
|||
|
|||
Assert.assertEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); |
|||
Assert.assertEquals(savedAiModel.getTenantId(), aiModelFromMsg.getTenantId()); |
|||
|
|||
// update AiModel
|
|||
edgeImitator.expectMessageAmount(1); |
|||
savedAiModel.setName(UPDATED_AI_MODEL_NAME); |
|||
savedAiModel = doPost("/api/ai/model", savedAiModel, AiModel.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); |
|||
aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; |
|||
aiModelFromMsg = JacksonUtil.fromString(aiModelUpdateMsg.getEntity(), AiModel.class, true); |
|||
Assert.assertNotNull(aiModelFromMsg); |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); |
|||
Assert.assertEquals(UPDATED_AI_MODEL_NAME, aiModelFromMsg.getName()); |
|||
|
|||
// delete AiModel
|
|||
edgeImitator.expectMessageAmount(1); |
|||
doDelete("/api/ai/model/" + savedAiModel.getUuidId()) |
|||
.andExpect(status().isOk()); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AiModelUpdateMsg); |
|||
aiModelUpdateMsg = (AiModelUpdateMsg) latestMessage; |
|||
Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, aiModelUpdateMsg.getMsgType()); |
|||
Assert.assertEquals(savedAiModel.getUuidId().getMostSignificantBits(), aiModelUpdateMsg.getIdMSB()); |
|||
Assert.assertEquals(savedAiModel.getUuidId().getLeastSignificantBits(), aiModelUpdateMsg.getIdLSB()); |
|||
} |
|||
|
|||
@Test |
|||
public void testSendAiModelToCloud() throws Exception { |
|||
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); |
|||
UUID uuid = Uuids.timeBased(); |
|||
UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); |
|||
|
|||
checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); |
|||
} |
|||
|
|||
@Test |
|||
public void testUpdateAiModelNameOnCloud() throws Exception { |
|||
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); |
|||
UUID uuid = Uuids.timeBased(); |
|||
UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); |
|||
|
|||
checkAiModelOnCloud(uplinkMsg, uuid, aiModel.getName()); |
|||
|
|||
aiModel.setName(UPDATED_AI_MODEL_NAME); |
|||
UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); |
|||
|
|||
checkAiModelOnCloud(updatedUplinkMsg, uuid, aiModel.getName()); |
|||
} |
|||
|
|||
@Test |
|||
public void testAiModelToCloudWithNameThatAlreadyExistsOnCloud() throws Exception { |
|||
AiModel aiModel = createSimpleAiModel(DEFAULT_AI_MODEL_NAME); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
AiModel savedAiModel = doPost("/api/ai/model", aiModel, AiModel.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
UUID uuid = Uuids.timeBased(); |
|||
|
|||
UplinkMsg uplinkMsg = getUplinkMsg(uuid, aiModel, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); |
|||
|
|||
edgeImitator.expectResponsesAmount(1); |
|||
edgeImitator.expectMessageAmount(1); |
|||
edgeImitator.sendUplinkMsg(uplinkMsg); |
|||
|
|||
Assert.assertTrue(edgeImitator.waitForResponses()); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
Optional<AiModelUpdateMsg> aiModelUpdateMsgOpt = edgeImitator.findMessageByType(AiModelUpdateMsg.class); |
|||
Assert.assertTrue(aiModelUpdateMsgOpt.isPresent()); |
|||
AiModelUpdateMsg latestAiModelUpdateMsg = aiModelUpdateMsgOpt.get(); |
|||
AiModel aiModelFromMsg = JacksonUtil.fromString(latestAiModelUpdateMsg.getEntity(), AiModel.class, true); |
|||
Assert.assertNotNull(aiModelFromMsg); |
|||
Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromMsg.getName()); |
|||
|
|||
Assert.assertNotEquals(savedAiModel.getUuidId(), uuid); |
|||
|
|||
AiModel aiModelFromCloud = doGet("/api/ai/model/" + uuid, AiModel.class); |
|||
Assert.assertNotNull(aiModelFromCloud); |
|||
Assert.assertNotEquals(DEFAULT_AI_MODEL_NAME, aiModelFromCloud.getName()); |
|||
} |
|||
|
|||
private AiModel createSimpleAiModel(String name) { |
|||
AiModel aiModel = new AiModel(); |
|||
aiModel.setTenantId(tenantId); |
|||
aiModel.setName(name); |
|||
aiModel.setConfiguration(OpenAiChatModelConfig.builder() |
|||
.providerConfig(new OpenAiProviderConfig(null, "test-api-key")) |
|||
.modelId("gpt-4o") |
|||
.temperature(0.5) |
|||
.topP(0.3) |
|||
.frequencyPenalty(0.1) |
|||
.presencePenalty(0.2) |
|||
.maxOutputTokens(1000) |
|||
.timeoutSeconds(60) |
|||
.maxRetries(2) |
|||
.build()); |
|||
return aiModel; |
|||
} |
|||
|
|||
private UplinkMsg getUplinkMsg(UUID uuid, AiModel aiModel, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { |
|||
UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); |
|||
AiModelUpdateMsg.Builder aiModelUpdateMsgBuilder = AiModelUpdateMsg.newBuilder(); |
|||
aiModelUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); |
|||
aiModelUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); |
|||
aiModelUpdateMsgBuilder.setEntity(JacksonUtil.toString(aiModel)); |
|||
aiModelUpdateMsgBuilder.setMsgType(updateMsgType); |
|||
testAutoGeneratedCodeByProtobuf(aiModelUpdateMsgBuilder); |
|||
uplinkMsgBuilder.addAiModelUpdateMsg(aiModelUpdateMsgBuilder.build()); |
|||
|
|||
testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); |
|||
|
|||
return uplinkMsgBuilder.build(); |
|||
} |
|||
|
|||
private void checkAiModelOnCloud(UplinkMsg uplinkMsg, UUID uuid, String resourceTitle) throws Exception { |
|||
edgeImitator.expectResponsesAmount(1); |
|||
edgeImitator.sendUplinkMsg(uplinkMsg); |
|||
|
|||
Assert.assertTrue(edgeImitator.waitForResponses()); |
|||
|
|||
UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); |
|||
Assert.assertTrue(latestResponseMsg.getSuccess()); |
|||
|
|||
AiModel aiModel = doGet("/api/ai/model/" + uuid, AiModel.class); |
|||
Assert.assertNotNull(aiModel); |
|||
Assert.assertEquals(resourceTitle, aiModel.getName()); |
|||
} |
|||
|
|||
} |
|||
@ -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(); |
|||
} |
|||
|
|||
} |
|||
@ -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); |
|||
} |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue