diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 303515ca61..688096dcae 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -24,6 +24,8 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityInfo; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -618,26 +620,28 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes } @Test - public void testGeofencingCalculatedField_SingleZonePerGroup() throws Exception { + public void testGeofencingCalculatedField_withoutRelationsCreationAndDynamicRefresh() throws Exception { // --- Arrange entities --- Device device = createDevice("GF Device", "sn-geo-1"); // Allowed zone polygon (square) String allowedPolygon = """ - {"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"} - """; + {"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"} + """; // Restricted zone polygon (square) String restrictedPolygon = """ - {"type":"POLYGON","polygonsDefinition":"[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"} - """; + {"type":"POLYGON","polygonsDefinition":"[[50.475000, 30.510000], [50.475000, 30.512000], [50.477000, 30.512000], [50.477000, 30.510000]]"} + """; Asset allowedZoneAsset = createAsset("Allowed Zone", null); doPost("/api/plugins/telemetry/ASSET/" + allowedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, - JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk());; + JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygon + "}")).andExpect(status().isOk()); + ; Asset restrictedZoneAsset = createAsset("Restricted Zone", null); doPost("/api/plugins/telemetry/ASSET/" + restrictedZoneAsset.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, - JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk());; + JacksonUtil.toJsonNode("{\"zone\":" + restrictedPolygon + "}")).andExpect(status().isOk()); + ; // Relations from device to zones EntityRelation deviceToAllowedZoneRelation = new EntityRelation(); @@ -748,6 +752,153 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testGeofencingCalculatedField_DynamicRefresh_RebindsZoneArguments() throws Exception { + // --- Update min allowed scheduled update intervals for CFs --- + loginSysAdmin(); + EntityInfo tenantProfileEntityInfo = doGet("/api/tenantProfileInfo/default", EntityInfo.class); + assertThat(tenantProfileEntityInfo).isNotNull(); + TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + tenantProfileEntityInfo.getId().getId().toString(), TenantProfile.class); + assertThat(foundTenantProfile).isNotNull(); + assertThat(foundTenantProfile.getDefaultProfileConfiguration()).isNotNull(); + foundTenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(TIMEOUT / 10); + TenantProfile savedTenantProfile = doPost("/api/tenantProfile", foundTenantProfile, TenantProfile.class); + assertThat(savedTenantProfile).isNotNull(); + assertThat(savedTenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF()).isEqualTo(TIMEOUT / 10); + loginTenantAdmin(); + + // --- Arrange entities --- + Device device = createDevice("GF Device dyn", "sn-geo-dyn-1"); + + // Allowed Zone A: covers initial point (ENTERED) + String allowedPolygonA = """ + {"type":"POLYGON","polygonsDefinition":"[[50.472000, 30.504000], [50.472000, 30.506000], [50.474000, 30.506000], [50.474000, 30.504000]]"} + """; + + Asset allowedZoneA = createAsset("Allowed Zone A", null); + doPost("/api/plugins/telemetry/ASSET/" + allowedZoneA.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonA + "}")).andExpect(status().isOk()); + + // Relation from device to Allowed Zone A + EntityRelation relAllowedA = new EntityRelation(); + relAllowedA.setFrom(device.getId()); + relAllowedA.setTo(allowedZoneA.getId()); + relAllowedA.setType("AllowedZone"); + doPost("/api/relation", relAllowedA).andExpect(status().isOk()); + + // Initial device coordinates: INSIDE Zone A + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4730,\"longitude\":30.5050}")).andExpect(status().isOk()); + + // --- Build CF: GEOFENCING with dynamic 'allowedZones' and short scheduled refresh --- + CalculatedField cf = new CalculatedField(); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("Geofencing CF (dynamic refresh)"); + cf.setDebugSettings(DebugSettings.off()); + + GeofencingCalculatedFieldConfiguration cfg = new GeofencingCalculatedFieldConfiguration(); + + // Coordinates (TS_LATEST) + Argument lat = new Argument(); + lat.setRefEntityKey(new ReferencedEntityKey("latitude", ArgumentType.TS_LATEST, null)); + Argument lon = new Argument(); + lon.setRefEntityKey(new ReferencedEntityKey("longitude", ArgumentType.TS_LATEST, null)); + + // Dynamic group 'allowedZones' resolved by relations (FROM device -> assets of type AllowedZone) + Argument allowedZones = new Argument(); + var dyn = new RelationQueryDynamicSourceConfiguration(); + dyn.setDirection(EntitySearchDirection.FROM); + dyn.setRelationType("AllowedZone"); + dyn.setMaxLevel(1); + dyn.setFetchLastLevelOnly(true); + allowedZones.setRefEntityKey(new ReferencedEntityKey("zone", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + allowedZones.setRefDynamicSourceConfiguration(dyn); + + cfg.setArguments(Map.of( + GeofencingCalculatedFieldConfiguration.ENTITY_ID_LATITUDE_ARGUMENT_KEY, lat, + GeofencingCalculatedFieldConfiguration.ENTITY_ID_LONGITUDE_ARGUMENT_KEY, lon, + "allowedZones", allowedZones + )); + + // Report all events for the group + List reportEvents = Arrays.stream(GeofencingEvent.values()).toList(); + GeofencingZoneGroupConfiguration allowedCfg = new GeofencingZoneGroupConfiguration("allowedZone", reportEvents); + cfg.setZoneGroupConfigurations(Map.of("allowedZones", allowedCfg)); + + // Server attributes output + Output out = new Output(); + out.setType(OutputType.ATTRIBUTES); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + // Enable scheduled refresh with a 6-second interval + cfg.setScheduledUpdateIntervalSec(6); + + cf.setConfiguration(cfg); + CalculatedField savedCalculatedField = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(savedCalculatedField).isNotNull(); + assertThat(savedCalculatedField.getConfiguration().isScheduledUpdateEnabled()).isTrue(); + + // --- Assert initial evaluation (ENTERED) --- + await().alias("initial geofencing evaluation") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), "allowedZoneEvent"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(1); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZoneEvent", "ENTERED"); + }); + + // --- Move device OUTSIDE Zone A (expect LEFT) --- + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk()); + + await().alias("outside zone A (LEFT)") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), "allowedZoneEvent"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(1); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZoneEvent", "LEFT"); + }); + + // --- Create Allowed Zone B covering the CURRENT location --- + String allowedPolygonB = """ + {"type":"POLYGON","polygonsDefinition":"[[50.475500, 30.510500], [50.475500, 30.511500], [50.476500, 30.511500], [50.476500, 30.510500]]"} + """; + + Asset allowedZoneB = createAsset("Allowed Zone B", null); + doPost("/api/plugins/telemetry/ASSET/" + allowedZoneB.getUuidId() + "/attributes/" + DataConstants.SERVER_SCOPE, + JacksonUtil.toJsonNode("{\"zone\":" + allowedPolygonB + "}")).andExpect(status().isOk()); + + // Add a new relation + EntityRelation relAllowedB = new EntityRelation(); + relAllowedB.setFrom(device.getId()); + relAllowedB.setTo(allowedZoneB.getId()); + relAllowedB.setType("AllowedZone"); + doPost("/api/relation", relAllowedB).andExpect(status().isOk()); + + awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(device.getId(), savedCalculatedField.getId()); + + // --- Same coordinates as before, but now we expect ENTERED since a new zone is registered --- + doPost("/api/plugins/telemetry/DEVICE/" + device.getUuidId() + "/timeseries/unusedScope", + JacksonUtil.toJsonNode("{\"latitude\":50.4760,\"longitude\":30.5110}")).andExpect(status().isOk()); + + // --- Assert dynamic refresh picks up new relation and flips event back to ENTERED on the next telemetry update --- + await().alias("dynamic refresh rebinds allowedZones") + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + ArrayNode attrs = getServerAttributes(device.getId(), "allowedZoneEvent"); + assertThat(attrs).isNotNull().isNotEmpty().hasSize(1); + Map m = kv(attrs); + assertThat(m).containsEntry("allowedZoneEvent", "ENTERED"); + }); + } + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index b93ff0f623..fd01581e36 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -68,7 +68,10 @@ import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.actors.DefaultTbActorSystem; import org.thingsboard.server.actors.TbActorId; import org.thingsboard.server.actors.TbActorMailbox; +import org.thingsboard.server.actors.TbCalculatedFieldEntityActorId; import org.thingsboard.server.actors.TbEntityActorId; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityActor; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldEntityMessageProcessor; import org.thingsboard.server.actors.device.DeviceActor; import org.thingsboard.server.actors.device.DeviceActorMessageProcessor; import org.thingsboard.server.actors.device.SessionInfo; @@ -99,6 +102,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; @@ -150,6 +154,7 @@ import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.memory.InMemoryStorage; import org.thingsboard.server.service.cf.CfRocksDb; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldState; import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest; import org.thingsboard.server.service.security.auth.rest.LoginRequest; @@ -1099,6 +1104,17 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { }); } + protected void awaitForCalculatedFieldEntityMessageProcessorToRegisterCfStateAsDirty(EntityId entityId, CalculatedFieldId cfId) { + CalculatedFieldEntityMessageProcessor processor = getCalculatedFieldEntityMessageProcessor(entityId); + Map statesMap = (Map) ReflectionTestUtils.getField(processor, "states"); + Awaitility.await("CF state for entity actor marked as dirty").atMost(5, TimeUnit.SECONDS).until(() -> { + CalculatedFieldState calculatedFieldState = statesMap.get(cfId); + boolean stateDirty = calculatedFieldState != null && calculatedFieldState.isDirty(); + log.warn("entityId {}, cfId {}, state dirty == {}", entityId, cfId, stateDirty); + return stateDirty; + }); + } + protected static String getMapName(FeatureType featureType) { switch (featureType) { case ATTRIBUTES: @@ -1120,6 +1136,16 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return (DeviceActorMessageProcessor) ReflectionTestUtils.getField(actor, "processor"); } + protected CalculatedFieldEntityMessageProcessor getCalculatedFieldEntityMessageProcessor(EntityId entityId) { + DefaultTbActorSystem actorSystem = (DefaultTbActorSystem) ReflectionTestUtils.getField(actorService, "system"); + ConcurrentMap actors = (ConcurrentMap) ReflectionTestUtils.getField(actorSystem, "actors"); + Awaitility.await("CF entity actor was created").atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> actors.containsKey(new TbCalculatedFieldEntityActorId(entityId))); + TbActorMailbox actorMailbox = actors.get(new TbCalculatedFieldEntityActorId(entityId)); + CalculatedFieldEntityActor actor = (CalculatedFieldEntityActor) ReflectionTestUtils.getField(actorMailbox, "actor"); + return (CalculatedFieldEntityMessageProcessor) ReflectionTestUtils.getField(actor, "processor"); + } + protected void updateDefaultTenantProfileConfig(Consumer updater) throws ThingsboardException { updateDefaultTenantProfile(tenantProfile -> { TenantProfileData profileData = tenantProfile.getProfileData();