From 57024c223e7ef682fdb6c2cc4a89767c4186f4b0 Mon Sep 17 00:00:00 2001
From: Paolo Cristiani <42511852+pgrisu@users.noreply.github.com>
Date: Tue, 13 May 2025 14:44:27 +0200
Subject: [PATCH 001/143] feat: add tooltip stacked total option in time series
chart settings
---
...e-series-chart-basic-config.component.html | 5 +++
...ime-series-chart-basic-config.component.ts | 10 ++++-
.../chart/time-series-chart-tooltip.models.ts | 45 ++++++++++++++++++-
.../lib/chart/time-series-chart.models.ts | 1 +
...eries-chart-widget-settings.component.html | 5 +++
...-series-chart-widget-settings.component.ts | 9 +++-
.../assets/locale/locale.constant-en_US.json | 1 +
7 files changed, 73 insertions(+), 3 deletions(-)
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
index 61c9f89c7c..8d1b5eaad7 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html
@@ -311,6 +311,11 @@
+
+
+ {{ 'tooltip.show-stack-total' | translate }}
+
+
+
+
+ {{ 'tooltip.show-stack-total' | translate }}
+
+
+
+ {{ 'legend.show-total' | translate }}
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts
index 9890895938..1a75f95d2f 100644
--- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.ts
@@ -176,6 +176,7 @@ export abstract class LatestChartBasicConfigComponent !item.total);
}
legendLabelStyle: ComponentStyle;
@@ -92,6 +93,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit {
private shapeResize$: ResizeObserver;
private legendHorizontal: boolean;
+ private legendShowTotal: boolean;
private latestChart: TbLatestChart;
@@ -119,6 +121,7 @@ export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit {
this.legendValueStyle = textStyle(this.settings.legendValueFont);
this.disabledLegendValueStyle = textStyle(this.settings.legendValueFont);
this.legendValueStyle.color = this.settings.legendValueColor;
+ this.legendShowTotal = this.settings.legendShowTotal;
}
}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts
index 931d3c52ed..9184bc59e7 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.models.ts
@@ -103,6 +103,7 @@ export interface LatestChartWidgetSettings extends LatestChartSettings {
legendLabelColor: string;
legendValueFont: Font;
legendValueColor: string;
+ legendShowTotal: boolean;
background: BackgroundSettings;
padding: string;
}
@@ -129,6 +130,7 @@ export const latestChartWidgetDefaultSettings: LatestChartWidgetSettings = {
lineHeight: '20px'
},
legendValueColor: 'rgba(0, 0, 0, 0.87)',
+ legendShowTotal: true,
background: {
type: BackgroundType.color,
color: '#fff',
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html
index 04476e69e2..5246747c20 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html
@@ -61,6 +61,9 @@
+
+ {{ 'legend.show-total' | translate }}
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts
index 1d7baebd33..b67a1d85f5 100644
--- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts
+++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.ts
@@ -134,6 +134,7 @@ export abstract class LatestChartWidgetSettingsComponent
Date: Thu, 10 Jul 2025 14:19:39 +0300
Subject: [PATCH 003/143] geofencing cf init commit
---
...CalculatedFieldEntityMessageProcessor.java | 27 +-
.../service/cf/CalculatedFieldResult.java | 10 +
...faultCalculatedFieldProcessingService.java | 92 ++++++-
.../service/cf/ctx/state/ArgumentEntry.java | 9 +-
.../cf/ctx/state/ArgumentEntryType.java | 2 +-
.../cf/ctx/state/CalculatedFieldCtx.java | 6 +-
.../cf/ctx/state/CalculatedFieldState.java | 3 +-
.../cf/ctx/state/GeofencingArgumentEntry.java | 85 ++++++
.../state/GeofencingCalculatedFieldState.java | 243 ++++++++++++++++++
.../ctx/state/ScriptCalculatedFieldState.java | 4 +-
.../ctx/state/SimpleCalculatedFieldState.java | 4 +-
.../server/utils/CalculatedFieldUtils.java | 4 +
.../common/data/cf/CalculatedFieldType.java | 2 +-
.../data/cf/configuration/Argument.java | 2 +
.../CFArgumentDynamicSourceType.java | 22 ++
.../CalculatedFieldConfiguration.java | 3 +-
.../CfArgumentDynamicSourceConfiguration.java | 39 +++
...eofencingCalculatedFieldConfiguration.java | 31 +++
...lationQueryDynamicSourceConfiguration.java | 57 ++++
.../util/geo/CirclePerimeterDefinition.java | 40 +++
.../common/util/geo/PerimeterDefinition.java | 39 +++
.../util/geo/PolygonPerimeterDefinition.java | 35 +++
22 files changed, 739 insertions(+), 20 deletions(-)
create mode 100644 application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java
create mode 100644 application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CFArgumentDynamicSourceType.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/GeofencingCalculatedFieldConfiguration.java
create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationQueryDynamicSourceConfiguration.java
create mode 100644 common/util/src/main/java/org/thingsboard/common/util/geo/CirclePerimeterDefinition.java
create mode 100644 common/util/src/main/java/org/thingsboard/common/util/geo/PerimeterDefinition.java
create mode 100644 common/util/src/main/java/org/thingsboard/common/util/geo/PolygonPerimeterDefinition.java
diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
index 35539834c3..75582ef4ba 100644
--- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java
@@ -288,17 +288,34 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM
boolean stateSizeChecked = false;
try {
if (ctx.isInitialized() && state.isReady()) {
- CalculatedFieldResult calculationResult = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
+ List calculationResults = state.performCalculation(ctx).get(systemContext.getCfCalculationResultTimeout(), TimeUnit.SECONDS);
state.checkStateSize(ctxId, ctx.getMaxStateSize());
stateSizeChecked = true;
if (state.isSizeOk()) {
- if (!calculationResult.isEmpty()) {
- cfService.pushMsgToRuleEngine(tenantId, entityId, calculationResult, cfIdList, callback);
- } else {
+ if (calculationResults.isEmpty()) {
callback.onSuccess();
+ } else {
+ TbCallback effectiveCallback = calculationResults.size() > 1 ?
+ new MultipleTbCallback(calculationResults.size(), callback) : callback;
+
+ for (CalculatedFieldResult calculationResult : calculationResults) {
+ if (calculationResult.isEmpty()) {
+ effectiveCallback.onSuccess();
+ } else {
+ cfService.pushMsgToRuleEngine(tenantId, entityId, calculationResult, cfIdList, callback);
+ }
+ }
}
if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) {
- systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArguments(), tbMsgId, tbMsgType, calculationResult.getResult().toString(), null);
+ if (calculationResults.isEmpty()) {
+ systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId,
+ state.getArguments(), tbMsgId, tbMsgType, null, null);
+ } else {
+ for (CalculatedFieldResult calculationResult : calculationResults) {
+ systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId,
+ state.getArguments(), tbMsgId, tbMsgType, calculationResult.getResultAsString(), null);
+ }
+ }
}
}
} else {
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
index 49acf6917c..7bec9ae964 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldResult.java
@@ -34,4 +34,14 @@ public final class CalculatedFieldResult {
(result.isTextual() && result.asText().isEmpty());
}
+ public String getResultAsString() {
+ if (result == null) {
+ return null;
+ }
+ if (result.isTextual()) {
+ return result.asText();
+ }
+ return result.toString();
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
index f2a6916751..9d75692718 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldProcessingService.java
@@ -32,12 +32,16 @@ import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.DataConstants;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
import org.thingsboard.server.common.data.cf.configuration.Argument;
+import org.thingsboard.server.common.data.cf.configuration.ArgumentType;
import org.thingsboard.server.common.data.cf.configuration.OutputType;
+import org.thingsboard.server.common.data.cf.configuration.RelationQueryDynamicSourceConfiguration;
import org.thingsboard.server.common.data.id.CalculatedFieldId;
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;
@@ -55,6 +59,7 @@ import org.thingsboard.server.common.msg.queue.ServiceType;
import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
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.gen.transport.TransportProtos.CalculatedFieldLinkedTelemetryMsgProto;
@@ -70,6 +75,7 @@ import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
import org.thingsboard.server.service.cf.ctx.state.ArgumentEntry;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldCtx;
import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState;
+import org.thingsboard.server.service.cf.ctx.state.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;
@@ -86,6 +92,10 @@ import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import static org.thingsboard.server.common.data.DataConstants.SCOPE;
+import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.ENTITY_ID_LATITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.ENTITY_ID_LONGITUDE_ARGUMENT_KEY;
+import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.RESTRICTED_ZONES_ARGUMENT_KEY;
+import static org.thingsboard.server.service.cf.ctx.state.GeofencingCalculatedFieldState.SAVE_ZONES_ARGUMENT_KEY;
import static org.thingsboard.server.utils.CalculatedFieldUtils.toProto;
@TbRuleEngineComponent
@@ -99,6 +109,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
private final TbClusterService clusterService;
private final ApiLimitService apiLimitService;
private final PartitionService partitionService;
+ private final RelationService relationService;
private ListeningExecutorService calculatedFieldCallbackExecutor;
@@ -118,11 +129,29 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
@Override
public ListenableFuture fetchStateFromDb(CalculatedFieldCtx ctx, EntityId entityId) {
Map> argFutures = new HashMap<>();
- for (var entry : ctx.getArguments().entrySet()) {
- var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
- var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue());
- argFutures.put(entry.getKey(), argValueFuture);
+
+ if (ctx.getCalculatedField().getType().equals(CalculatedFieldType.GEOFENCING)) {
+ // Ignoring any other arguments except ENTITY_ID_LATITUDE_ARGUMENT_KEY,
+ // ENTITY_ID_LONGITUDE_ARGUMENT_KEY, SAVE_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY.
+ for (var entry : ctx.getArguments().entrySet()) {
+ switch (entry.getKey()) {
+ case ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY ->
+ argFutures.put(entry.getKey(), fetchKvEntry(ctx.getTenantId(), resolveEntityId(entityId, entry), entry.getValue()));
+ case SAVE_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY -> {
+ var resolvedEntityIdsFuture = resolveGeofencingEntityIds(ctx.getTenantId(), entityId, entry);
+ argFutures.put(entry.getKey(), Futures.transformAsync(resolvedEntityIdsFuture, resolvedEntityIds ->
+ fetchGeofencingKvEntry(ctx.getTenantId(), resolvedEntityIds, entry.getValue()), MoreExecutors.directExecutor()));
+ }
+ }
+ }
+ } else {
+ for (var entry : ctx.getArguments().entrySet()) {
+ var argEntityId = resolveEntityId(entityId, entry);
+ var argValueFuture = fetchKvEntry(ctx.getTenantId(), argEntityId, entry.getValue());
+ argFutures.put(entry.getKey(), argValueFuture);
+ }
}
+
return Futures.whenAllComplete(argFutures.values()).call(() -> {
var result = createStateByType(ctx);
result.updateState(ctx, argFutures.entrySet().stream()
@@ -145,7 +174,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
public Map fetchArgsFromDb(TenantId tenantId, EntityId entityId, Map arguments) {
Map> argFutures = new HashMap<>();
for (var entry : arguments.entrySet()) {
- var argEntityId = entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
+ var argEntityId = resolveEntityId(entityId, entry);
var argValueFuture = fetchKvEntry(tenantId, argEntityId, entry.getValue());
argFutures.put(entry.getKey(), argValueFuture);
}
@@ -241,6 +270,58 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
return builder.build();
}
+
+ private EntityId resolveEntityId(EntityId entityId, Entry entry) {
+ return entry.getValue().getRefEntityId() != null ? entry.getValue().getRefEntityId() : entityId;
+ }
+
+ private ListenableFuture> resolveGeofencingEntityIds(TenantId tenantId, EntityId entityId, Entry entry) {
+ Argument value = entry.getValue();
+ if (value.getRefEntityId() != null) {
+ return Futures.immediateFuture(List.of(value.getRefEntityId()));
+ }
+ var refDynamicSource = value.getRefDynamicSource();
+ if (refDynamicSource == null) {
+ return Futures.immediateFuture(List.of(entityId));
+ }
+ return switch (value.getRefDynamicSource()) {
+ case RELATION_QUERY -> {
+ var relationQueryDynamicSourceConfiguration = (RelationQueryDynamicSourceConfiguration) value.getRefDynamicSourceConfiguration();
+ yield Futures.transform(relationService.findByQuery(tenantId, relationQueryDynamicSourceConfiguration.toEntityRelationsQuery(entityId)),
+ relationQueryDynamicSourceConfiguration::resolveEntityIds, MoreExecutors.directExecutor());
+ }
+ };
+ }
+
+ private ListenableFuture fetchGeofencingKvEntry(TenantId tenantId, List geofencingEntities, Argument argument) {
+ // TODO: Should we handle any other case?
+ if (argument.getRefEntityKey().getType() != ArgumentType.ATTRIBUTE) {
+ throw new IllegalStateException("Unsupported argument key type: " + argument.getRefEntityKey().getType());
+ }
+
+ List>> 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>> allFutures = Futures.allAsList(kvFutures);
+
+ return Futures.transform(allFutures, entries -> ArgumentEntry.createGeofencingValueArgument(entries.stream()
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))),
+ calculatedFieldCallbackExecutor
+ );
+ }
+
private ListenableFuture fetchKvEntry(TenantId tenantId, EntityId entityId, Argument argument) {
return switch (argument.getRefEntityKey().getType()) {
case TS_ROLLING -> fetchTsRolling(tenantId, entityId, argument);
@@ -301,6 +382,7 @@ public class DefaultCalculatedFieldProcessingService implements CalculatedFieldP
return switch (ctx.getCfType()) {
case SIMPLE -> new SimpleCalculatedFieldState(ctx.getArgNames());
case SCRIPT -> new ScriptCalculatedFieldState(ctx.getArgNames());
+ case GEOFENCING -> new GeofencingCalculatedFieldState(ctx.getArgNames());
};
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
index 83e10b8194..c7f830431b 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntry.java
@@ -19,10 +19,12 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.kv.KvEntry;
import org.thingsboard.server.common.data.kv.TsKvEntry;
import java.util.List;
+import java.util.Map;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
@@ -31,7 +33,8 @@ import java.util.List;
)
@JsonSubTypes({
@JsonSubTypes.Type(value = SingleValueArgumentEntry.class, name = "SINGLE_VALUE"),
- @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING")
+ @JsonSubTypes.Type(value = TsRollingArgumentEntry.class, name = "TS_ROLLING"),
+ @JsonSubTypes.Type(value = GeofencingArgumentEntry.class, name = "GEOFENCING")
})
public interface ArgumentEntry {
@@ -58,4 +61,8 @@ public interface ArgumentEntry {
return new TsRollingArgumentEntry(kvEntries, limit, timeWindow);
}
+ static ArgumentEntry createGeofencingValueArgument(Map entityIdkvEntryMap) {
+ return new GeofencingArgumentEntry(entityIdkvEntryMap);
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
index 68f973c7c1..876bfa2a3f 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ArgumentEntryType.java
@@ -16,5 +16,5 @@
package org.thingsboard.server.service.cf.ctx.state;
public enum ArgumentEntryType {
- SINGLE_VALUE, TS_ROLLING
+ SINGLE_VALUE, TS_ROLLING, GEOFENCING
}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
index 2e3321eece..89f76bd482 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java
@@ -59,6 +59,7 @@ public class CalculatedFieldCtx {
private final Map arguments;
private final Map mainEntityArguments;
private final Map> linkedEntityArguments;
+ private final Map dynamicEntityArguments;
private final List argNames;
private Output output;
private String expression;
@@ -84,10 +85,13 @@ public class CalculatedFieldCtx {
this.arguments = configuration.getArguments();
this.mainEntityArguments = new HashMap<>();
this.linkedEntityArguments = new HashMap<>();
+ this.dynamicEntityArguments = new HashMap<>();
for (Map.Entry entry : arguments.entrySet()) {
var refId = entry.getValue().getRefEntityId();
var refKey = entry.getValue().getRefEntityKey();
- if (refId == null || refId.equals(calculatedField.getEntityId())) {
+ if (refId == null && entry.getValue().getRefDynamicSource() != null) {
+ dynamicEntityArguments.put(refKey, entry.getKey());
+ } else if (refId == null || refId.equals(calculatedField.getEntityId())) {
mainEntityArguments.put(refKey, entry.getKey());
} else {
linkedEntityArguments.computeIfAbsent(refId, key -> new HashMap<>()).put(refKey, entry.getKey());
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
index 0de354bbb0..dc98ed836c 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldState.java
@@ -34,6 +34,7 @@ import java.util.Map;
@JsonSubTypes({
@JsonSubTypes.Type(value = SimpleCalculatedFieldState.class, name = "SIMPLE"),
@JsonSubTypes.Type(value = ScriptCalculatedFieldState.class, name = "SCRIPT"),
+ @JsonSubTypes.Type(value = GeofencingCalculatedFieldState.class, name = "GEOFENCING"),
})
public interface CalculatedFieldState {
@@ -48,7 +49,7 @@ public interface CalculatedFieldState {
boolean updateState(CalculatedFieldCtx ctx, Map argumentValues);
- ListenableFuture performCalculation(CalculatedFieldCtx ctx);
+ ListenableFuture> performCalculation(CalculatedFieldCtx ctx);
@JsonIgnore
boolean isReady();
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java
new file mode 100644
index 0000000000..51f5d4fd4f
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingArgumentEntry.java
@@ -0,0 +1,85 @@
+/**
+ * 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 lombok.Data;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.common.util.geo.PerimeterDefinition;
+import org.thingsboard.script.api.tbel.TbelCfArg;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.common.data.kv.KvEntry;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+// TODO: implement
+@Data
+public class GeofencingArgumentEntry implements ArgumentEntry {
+
+ private Map geofencingIdToPerimeter;
+ private boolean forceResetPrevious;
+
+ public GeofencingArgumentEntry(Map entityIdKvEntryMap) {
+ this.geofencingIdToPerimeter = toPerimetersMap(entityIdKvEntryMap);
+ }
+
+ @Override
+ public ArgumentEntryType getType() {
+ return ArgumentEntryType.GEOFENCING;
+ }
+
+ @Override
+ public Object getValue() {
+ return geofencingIdToPerimeter;
+ }
+
+ @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 (Objects.equals(this.geofencingIdToPerimeter, geofencingArgumentEntry.getGeofencingIdToPerimeter())) {
+ return false; // No change
+ }
+ this.geofencingIdToPerimeter = geofencingArgumentEntry.getGeofencingIdToPerimeter();
+ return true;
+ }
+
+ @Override
+ public boolean isEmpty() {
+ return geofencingIdToPerimeter == null || geofencingIdToPerimeter.isEmpty();
+ }
+
+ @Override
+ public TbelCfArg toTbelCfArg() {
+ return null;
+ }
+
+ private Map toPerimetersMap(Map entityIdKvEntryMap) {
+ return entityIdKvEntryMap.entrySet().stream().map(entry -> {
+ if (entry.getValue().getJsonValue().isEmpty()) {
+ return null;
+ }
+ String rawPerimeterValue = entry.getValue().getJsonValue().get();
+ PerimeterDefinition perimeter = JacksonUtil.fromString(rawPerimeterValue, PerimeterDefinition.class);
+ return Map.entry(entry.getKey(), perimeter);
+ })
+ .filter(Objects::nonNull)
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java
new file mode 100644
index 0000000000..11853e7823
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/GeofencingCalculatedFieldState.java
@@ -0,0 +1,243 @@
+/**
+ * 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.fasterxml.jackson.databind.node.ObjectNode;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import lombok.Data;
+import org.thingsboard.common.util.JacksonUtil;
+import org.thingsboard.common.util.geo.Coordinates;
+import org.thingsboard.common.util.geo.PerimeterDefinition;
+import org.thingsboard.rule.engine.geo.EntityGeofencingState;
+import org.thingsboard.rule.engine.util.GpsGeofencingEvents;
+import org.thingsboard.server.common.data.cf.CalculatedFieldType;
+import org.thingsboard.server.common.data.id.EntityId;
+import org.thingsboard.server.service.cf.CalculatedFieldResult;
+import org.thingsboard.server.service.cf.ctx.CalculatedFieldEntityCtxId;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+@Data
+public class GeofencingCalculatedFieldState implements CalculatedFieldState {
+
+ public static final String ENTITY_ID_LATITUDE_ARGUMENT_KEY = "latitude";
+ public static final String ENTITY_ID_LONGITUDE_ARGUMENT_KEY = "longitude";
+ public static final String SAVE_ZONES_ARGUMENT_KEY = "saveZones";
+ public static final String RESTRICTED_ZONES_ARGUMENT_KEY = "restrictedZones";
+
+ private List requiredArguments;
+ private Map arguments;
+ private long latestTimestamp = -1;
+
+ private Map saveZoneStates;
+ private Map restrictedZoneStates;
+
+ public GeofencingCalculatedFieldState() {
+ this(List.of(ENTITY_ID_LATITUDE_ARGUMENT_KEY, ENTITY_ID_LONGITUDE_ARGUMENT_KEY, SAVE_ZONES_ARGUMENT_KEY, RESTRICTED_ZONES_ARGUMENT_KEY));
+ }
+
+ public GeofencingCalculatedFieldState(List argNames) {
+ this.requiredArguments = argNames;
+ this.arguments = new HashMap<>();
+ this.saveZoneStates = new HashMap<>();
+ this.restrictedZoneStates = new HashMap<>();
+ }
+
+ @Override
+ public CalculatedFieldType getType() {
+ return CalculatedFieldType.GEOFENCING;
+ }
+
+ @Override
+ public boolean updateState(CalculatedFieldCtx ctx, Map argumentValues) {
+ // TODO: Do I need to check argument for null?
+ if (arguments == null) {
+ arguments = new HashMap<>();
+ }
+
+ boolean stateUpdated = false;
+
+ for (Map.Entry entry : argumentValues.entrySet()) {
+ String key = entry.getKey();
+ ArgumentEntry newEntry = entry.getValue();
+
+ // TODO: Do I need to check argument size?
+ // checkArgumentSize(key, newEntry, ctx);
+
+ ArgumentEntry existingEntry = arguments.get(key);
+ boolean entryUpdated;
+
+ // TODO: What is force reset previos?
+ // if (existingEntry == null || newEntry.isForceResetPrevious()) {
+
+ // fresh start of state. No entry exists yet.
+ if (existingEntry == null) {
+ switch (key) {
+ case ENTITY_ID_LATITUDE_ARGUMENT_KEY:
+ case ENTITY_ID_LONGITUDE_ARGUMENT_KEY:
+ if (!(newEntry instanceof SingleValueArgumentEntry singleValueArgumentEntry)) {
+ throw new IllegalArgumentException(key + " argument must be a single value argument.");
+ }
+ arguments.put(key, singleValueArgumentEntry);
+ entryUpdated = true;
+ break;
+ case SAVE_ZONES_ARGUMENT_KEY:
+ case RESTRICTED_ZONES_ARGUMENT_KEY:
+ if (!(newEntry instanceof GeofencingArgumentEntry geofencingArgumentEntry)) {
+ throw new IllegalArgumentException(key + " argument must be a geofencing argument entry.");
+ }
+ arguments.put(key, geofencingArgumentEntry);
+ entryUpdated = true;
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported argument: " + key);
+ }
+ } else {
+ entryUpdated = switch (key) {
+ case ENTITY_ID_LATITUDE_ARGUMENT_KEY,
+ ENTITY_ID_LONGITUDE_ARGUMENT_KEY -> existingEntry.updateEntry(newEntry);
+ case SAVE_ZONES_ARGUMENT_KEY,
+ RESTRICTED_ZONES_ARGUMENT_KEY -> {
+ // TODO: ensure zone cleanup working correctly.
+ boolean updated = existingEntry.updateEntry(newEntry);
+ if (updated) {
+ Map currentStates =
+ key.equals(SAVE_ZONES_ARGUMENT_KEY) ? saveZoneStates : restrictedZoneStates;
+ Set newZoneIds = ((GeofencingArgumentEntry) newEntry).getGeofencingIdToPerimeter().keySet();
+ currentStates.keySet().removeIf(existingZoneId -> !newZoneIds.contains(existingZoneId));
+ }
+ yield updated;
+ }
+ default -> throw new IllegalStateException("Unsupported argument: " + key);
+ };
+ }
+
+ if (entryUpdated) {
+ stateUpdated = true;
+ updateLastUpdateTimestamp(newEntry);
+ }
+ }
+ return stateUpdated;
+ }
+
+
+ @Override
+ public ListenableFuture> performCalculation(CalculatedFieldCtx ctx) {
+ List savedZonesStatesResults = updateSavedGeofencingZonesState(ctx);
+ List restrictedZonesStatesResults = updateRestrictedGeofencingZonesState(ctx);
+
+ List allZoneStatesResults =
+ new ArrayList<>(savedZonesStatesResults.size() + restrictedZonesStatesResults.size());
+ allZoneStatesResults.addAll(savedZonesStatesResults);
+ allZoneStatesResults.addAll(restrictedZonesStatesResults);
+
+ return Futures.immediateFuture(allZoneStatesResults);
+ }
+
+ @Override
+ public boolean isReady() {
+ return arguments.keySet().containsAll(requiredArguments) &&
+ arguments.values().stream().noneMatch(ArgumentEntry::isEmpty);
+ }
+
+ // TODO: implement
+ @Override
+ public boolean isSizeExceedsLimit() {
+ return false;
+ }
+
+ // TODO: implement
+ @Override
+ public void checkStateSize(CalculatedFieldEntityCtxId ctxId, long maxStateSize) {
+
+ }
+
+ // TODO: implement
+ @Override
+ public void checkArgumentSize(String name, ArgumentEntry entry, CalculatedFieldCtx ctx) {
+
+ }
+
+ private void updateLastUpdateTimestamp(ArgumentEntry entry) {
+ long newTs = this.latestTimestamp;
+ if (entry instanceof SingleValueArgumentEntry singleValueArgumentEntry) {
+ newTs = singleValueArgumentEntry.getTs();
+ }
+ this.latestTimestamp = Math.max(this.latestTimestamp, newTs);
+ }
+
+ private List updateSavedGeofencingZonesState(CalculatedFieldCtx ctx) {
+ return updateGeofencingZonesState(ctx, saveZoneStates, false);
+ }
+
+ private List updateRestrictedGeofencingZonesState(CalculatedFieldCtx ctx) {
+ return updateGeofencingZonesState(ctx, restrictedZoneStates, true);
+ }
+
+ // TODO: Ensure all cases are covered based on rule node logic.
+ private List updateGeofencingZonesState(CalculatedFieldCtx ctx, Map zoneStates, boolean restricted) {
+ var results = new ArrayList();
+
+ long stateSwitchTime = System.currentTimeMillis();
+ 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);
+
+ String zoneKey = restricted ? RESTRICTED_ZONES_ARGUMENT_KEY : SAVE_ZONES_ARGUMENT_KEY;
+ GeofencingArgumentEntry zonesEntry = (GeofencingArgumentEntry) arguments.get(zoneKey);
+
+ for (Map.Entry entry : zonesEntry.getGeofencingIdToPerimeter().entrySet()) {
+ EntityId zoneId = entry.getKey();
+ PerimeterDefinition perimeter = entry.getValue();
+
+ boolean inside = perimeter.checkMatches(entityCoordinates);
+
+ // Always present or created
+ EntityGeofencingState state = zoneStates.computeIfAbsent(
+ zoneId, id -> new EntityGeofencingState(false, 0L, false)
+ );
+
+ String event;
+ if (state.getStateSwitchTime() == 0L || state.isInside() != inside) {
+ // First state or transition (entered/left)
+ state.setInside(inside);
+ state.setStateSwitchTime(stateSwitchTime);
+ state.setStayed(false);
+
+ event = inside ? GpsGeofencingEvents.ENTERED : GpsGeofencingEvents.LEFT;
+ } else {
+ // No transition
+ event = inside ? GpsGeofencingEvents.INSIDE : GpsGeofencingEvents.OUTSIDE;
+ }
+
+ ObjectNode stateNode = JacksonUtil.newObjectNode();
+ stateNode.put("entityId", ctx.getEntityId().toString());
+ stateNode.put("zoneId", zoneId.getId().toString());
+ stateNode.put("restricted", restricted);
+ stateNode.put("event", event);
+
+ results.add(new CalculatedFieldResult(ctx.getOutput().getType(), ctx.getOutput().getScope(), stateNode));
+ }
+
+ return results;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
index 84dce627ae..b1095cf13f 100644
--- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
+++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/ScriptCalculatedFieldState.java
@@ -53,7 +53,7 @@ public class ScriptCalculatedFieldState extends BaseCalculatedFieldState {
}
@Override
- public ListenableFuture performCalculation(CalculatedFieldCtx ctx) {
+ public ListenableFuture> performCalculation(CalculatedFieldCtx ctx) {
Map arguments = new LinkedHashMap<>();
List