diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 2f9c0cd302..8cb79674bf 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -20,6 +20,12 @@ ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; -- CALCULATED FIELD ADDITIONAL INFO ADDITION END +-- CALCULATED FIELD ENABLED ADDITION START + +ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS enabled boolean NOT NULL DEFAULT true; + +-- CALCULATED FIELD ENABLED ADDITION END + -- RULE CHAIN NOTES MIGRATION START ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000); diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index a1d7477a36..710a66c6bb 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -417,8 +417,8 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware callback.onSuccess(); } else { var cf = cfDaoService.findById(msg.getTenantId(), cfId); - if (cf == null) { - log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); + if (cf == null || !cf.isEnabled()) { + log.debug("[{}] CF not found or disabled [{}]", tenantId, cfId); callback.onSuccess(); } else { var cfCtx = getCfCtx(cf); @@ -451,6 +451,9 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware if (newCf == null) { log.debug("[{}] Failed to lookup CF by id [{}]", tenantId, cfId); callback.onSuccess(); + } else if (!newCf.isEnabled()) { + log.debug("[{}] CF disabled, treating as delete [{}]", tenantId, cfId); + onCfDeleted(msg, callback); } else { var newCfCtx = getCfCtx(newCf); try { @@ -802,7 +805,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware } public void initCalculatedFields() { - PageDataIterable cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize()); + PageDataIterable cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantIdAndEnabled(tenantId, true, pageLink), cfSettings.getInitTenantFetchPackSize()); cfs.forEach(cf -> { log.trace("Processing calculated field record: {}", cf); try { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index 3a989aaa60..ddbc847606 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -198,6 +198,10 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { if (calculatedField == null) { return; } + if (!calculatedField.isEnabled()) { + log.debug("[{}] Skipping disabled calculated field, not caching for processing [{}]", tenantId, calculatedFieldId); + return; + } EntityId cfEntityId = calculatedField.getEntityId(); calculatedFields.put(calculatedFieldId, calculatedField); diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index 9a001e0575..966779c047 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -909,6 +909,161 @@ public class AlarmRulesTest extends AbstractControllerTest { }); } + @Test + public void testDisabledAlarmRuleDoesNotCreateAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of("temperature", temperatureArgument); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Disabled Alarm Test", + arguments, createRules, null); + + // Verify alarm is created when enabled + postTelemetry(deviceId, "{\"temperature\":50}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + }); + + // Disable the alarm rule + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + AlarmRuleDefinition disabled = saveAlarmRule(fetched); + assertThat(disabled.isEnabled()).isFalse(); + + // Send telemetry that would trigger alarm — should NOT produce new alarm result + postTelemetry(deviceId, "{\"temperature\":200}"); + + await().alias("disabled alarm rule should not process").atMost(TIMEOUT, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(getLatestAlarmResult(alarmRule.getId())).isNull()); + } + + @Test + public void testReEnabledAlarmRuleCreatesAlarm() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of("temperature", temperatureArgument); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + Condition clearRule = new Condition("return temperature < 50;", null, null); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Re-enable Alarm Test", + arguments, createRules, clearRule); + + // Create alarm + postTelemetry(deviceId, "{\"temperature\":60}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + }); + + // Clear alarm + postTelemetry(deviceId, "{\"temperature\":10}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + }); + + // Disable + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + saveAlarmRule(fetched); + + // Re-enable + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(true); + saveAlarmRule(fetched); + + // Trigger alarm again — re-enabled rule should process + postTelemetry(deviceId, "{\"temperature\":80}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + }); + } + + @Test + public void testEnableDisableCycles() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of("temperature", temperatureArgument); + + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) + ); + Condition clearRule = new Condition("return temperature < 50;", null, null); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Cycle Alarm Test", + arguments, createRules, clearRule); + + // --- Cycle 1: enabled → alarm → clear → disable → no alarm → re-enable → alarm --- + + postTelemetry(deviceId, "{\"temperature\":60}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + }); + + postTelemetry(deviceId, "{\"temperature\":10}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + }); + + // Disable + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":70}"); + await().alias("cycle 1: disabled, no alarm").atMost(TIMEOUT, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(getLatestAlarmResult(alarmRule.getId())).isNull()); + + // Re-enable + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(true); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":80}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + }); + + // Clear for cycle 2 + postTelemetry(deviceId, "{\"temperature\":10}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCleared()).isTrue(); + }); + + // --- Cycle 2: disable → no alarm → re-enable → alarm --- + + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(false); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":90}"); + await().alias("cycle 2: disabled, no alarm").atMost(TIMEOUT, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(getLatestAlarmResult(alarmRule.getId())).isNull()); + + fetched = doGet("/api/alarm/rule/" + alarmRule.getId().getId(), AlarmRuleDefinition.class); + fetched.setEnabled(true); + saveAlarmRule(fetched); + + postTelemetry(deviceId, "{\"temperature\":100}"); + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + }); + } + // TODO: MSA tests private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Consumer assertion) { 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 ba7d11fee9..67b2b2ccfb 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -1481,6 +1481,299 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testDisabledCalculatedFieldDoesNotProcess() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("enable-disable-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", argument)); + config.setExpression("T * 2"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("doubled"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + // Create CF (enabled by default) and verify it processes + CalculatedField savedCf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + postTelemetry(testDevice.getId(), "{\"temperature\":10}"); + + await().alias("enabled CF processes telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "doubled"); + assertThat(result).isNotNull(); + assertThat(result.get("doubled").get(0).get("value").asText()).isEqualTo("20.0"); + }); + + // Disable CF + savedCf = doGet("/api/calculatedField/" + savedCf.getId().getId(), CalculatedField.class); + savedCf.setEnabled(false); + savedCf = doPost("/api/calculatedField", savedCf, CalculatedField.class); + assertThat(savedCf.isEnabled()).isFalse(); + + // Send new telemetry — disabled CF should NOT recalculate + postTelemetry(testDevice.getId(), "{\"temperature\":50}"); + + await().alias("disabled CF should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "doubled"); + assertThat(result.get("doubled").get(0).get("value").asText()).isEqualTo("20.0"); + }); + + doDelete("/api/calculatedField/" + savedCf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testReEnabledCalculatedFieldProcesses() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("re-enable-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", argument)); + config.setExpression("T + 100"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("shifted"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + // Create CF, send telemetry, verify + CalculatedField savedCf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + postTelemetry(testDevice.getId(), "{\"temperature\":5}"); + + await().alias("initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "shifted"); + assertThat(result).isNotNull(); + assertThat(result.get("shifted").get(0).get("value").asText()).isEqualTo("105.0"); + }); + + // Disable + savedCf = doGet("/api/calculatedField/" + savedCf.getId().getId(), CalculatedField.class); + savedCf.setEnabled(false); + savedCf = doPost("/api/calculatedField", savedCf, CalculatedField.class); + + // Re-enable + savedCf = doGet("/api/calculatedField/" + savedCf.getId().getId(), CalculatedField.class); + savedCf.setEnabled(true); + savedCf = doPost("/api/calculatedField", savedCf, CalculatedField.class); + assertThat(savedCf.isEnabled()).isTrue(); + + // Send new telemetry — re-enabled CF should process + postTelemetry(testDevice.getId(), "{\"temperature\":20}"); + + await().alias("re-enabled CF processes telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "shifted"); + assertThat(result).isNotNull(); + assertThat(result.get("shifted").get(0).get("value").asText()).isEqualTo("120.0"); + }); + + doDelete("/api/calculatedField/" + savedCf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testMultipleEnableDisableCyclesWithTelemetry() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("multi-cycle-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("value", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("V", argument)); + config.setExpression("V * 10"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("result"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + // Create enabled CF + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + // --- Cycle 1: enabled → process → disable → skip → enable → process --- + + postTelemetry(testDevice.getId(), "{\"value\":1}"); + await().alias("cycle 1: enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("10.0"); + }); + + // Disable + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":2}"); + await().alias("cycle 1: disabled, should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("10.0"); + }); + + // Re-enable + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":3}"); + await().alias("cycle 1: re-enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("30.0"); + }); + + // --- Cycle 2: disable → skip → enable → process --- + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":4}"); + await().alias("cycle 2: disabled, should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("30.0"); + }); + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":5}"); + await().alias("cycle 2: re-enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("50.0"); + }); + + // --- Cycle 3: disable → skip → enable → process --- + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":6}"); + await().alias("cycle 3: disabled, should not recalculate").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("50.0"); + }); + + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"value\":7}"); + await().alias("cycle 3: re-enabled, processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "result"); + assertThat(r.get("result").get(0).get("value").asText()).isEqualTo("70.0"); + }); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDisabledCalculatedFieldWhenEntityIdIsProfile() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + + CalculatedField calculatedField = new CalculatedField(); + calculatedField.setEntityId(testDevice.getDeviceProfileId()); + calculatedField.setType(CalculatedFieldType.SIMPLE); + calculatedField.setName("profile-enable-disable-test"); + calculatedField.setConfigurationVersion(1); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", argument)); + config.setExpression("T + 1"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("temp_plus_one"); + config.setOutput(output); + calculatedField.setConfiguration(config); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"temperature\":10}"); + await().alias("profile CF processes device telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "temp_plus_one"); + assertThat(result).isNotNull(); + assertThat(result.get("temp_plus_one").get(0).get("value").asText()).isEqualTo("11.0"); + }); + + // Disable profile-level CF + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + // Device telemetry should not be processed + postTelemetry(testDevice.getId(), "{\"temperature\":50}"); + + await().alias("disabled profile CF should not process device telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "temp_plus_one"); + assertThat(result.get("temp_plus_one").get(0).get("value").asText()).isEqualTo("11.0"); + }); + + // Re-enable + cf = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + postTelemetry(testDevice.getId(), "{\"temperature\":99}"); + await().alias("re-enabled profile CF processes").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode r = getLatestTelemetry(testDevice.getId(), "temp_plus_one"); + assertThat(r.get("temp_plus_one").get(0).get("value").asText()).isEqualTo("100.0"); + }); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + 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/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index 18e11faaf9..e723b96fa7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -297,6 +297,122 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(names.getData()).containsOnly(deviceCalculatedField.getName()); } + @Test + public void testCalculatedFieldEnabledByDefault() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + + assertThat(savedCalculatedField.isEnabled()).isTrue(); + + CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + savedCalculatedField.getId().getId(), CalculatedField.class); + assertThat(fetchedCalculatedField.isEnabled()).isTrue(); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDisableCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField savedCalculatedField = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + assertThat(savedCalculatedField.isEnabled()).isTrue(); + + savedCalculatedField.setEnabled(false); + CalculatedField disabledCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); + + assertThat(disabledCalculatedField.isEnabled()).isFalse(); + assertThat(disabledCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); + + CalculatedField fetchedCalculatedField = doGet("/api/calculatedField/" + disabledCalculatedField.getId().getId(), CalculatedField.class); + assertThat(fetchedCalculatedField.isEnabled()).isFalse(); + + doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testEnableDisableCycles() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + assertThat(cf.isEnabled()).isTrue(); + long version = cf.getVersion(); + + // Cycle 1: disable + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 1: re-enable + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isTrue(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 2: disable again + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 2: re-enable again + cf.setEnabled(true); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isTrue(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Cycle 3: disable + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + assertThat(cf.getVersion()).isEqualTo(++version); + + // Verify final state via GET + CalculatedField fetched = doGet("/api/calculatedField/" + cf.getId().getId(), CalculatedField.class); + assertThat(fetched.isEnabled()).isFalse(); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDisabledCalculatedFieldVisibleInList() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + + assertThat(getEntityCalculatedFields(testDevice.getId(), null, new PageLink(10)).getData()) + .singleElement() + .satisfies(found -> assertThat(found.isEnabled()).isFalse()); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testDeleteDisabledCalculatedField() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField calculatedField = getSimpleCalculatedField(testDevice.getId()); + + CalculatedField cf = doPost("/api/calculatedField", calculatedField, CalculatedField.class); + cf.setEnabled(false); + cf = doPost("/api/calculatedField", cf, CalculatedField.class); + assertThat(cf.isEnabled()).isFalse(); + + doDelete("/api/calculatedField/" + cf.getId().getId().toString()) + .andExpect(status().isOk()); + doGet("/api/calculatedField/" + cf.getId().getId()).andExpect(status().isNotFound()); + } + @Test public void testDeleteCalculatedField() throws Exception { Device testDevice = createDevice("Test device", "1234567890"); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java index 1603385094..1ec9abf310 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java @@ -46,6 +46,8 @@ public interface CalculatedFieldService extends EntityDaoService { PageData findCalculatedFieldsByTenantId(TenantId tenantId, PageLink pageLink); + PageData findCalculatedFieldsByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink); + PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink); PageData findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java index bd0c76bc05..86c238efc0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java @@ -44,6 +44,8 @@ public class AlarmRuleDefinition extends BaseData implements private TenantId tenantId; private EntityId entityId; + @Schema(description = "Enable/disable the alarm rule.", example = "true") + private boolean enabled = true; @NoXss @Length(fieldName = "name") @@ -78,6 +80,7 @@ public class AlarmRuleDefinition extends BaseData implements super(alarmRuleDefinition); this.tenantId = alarmRuleDefinition.tenantId; this.entityId = alarmRuleDefinition.entityId; + this.enabled = alarmRuleDefinition.enabled; this.name = alarmRuleDefinition.name; this.debugMode = alarmRuleDefinition.debugMode; this.debugSettings = alarmRuleDefinition.debugSettings; @@ -117,6 +120,7 @@ public class AlarmRuleDefinition extends BaseData implements cf.setCreatedTime(this.createdTime); cf.setTenantId(this.tenantId); cf.setEntityId(this.entityId); + cf.setEnabled(this.enabled); cf.setType(CalculatedFieldType.ALARM); cf.setName(this.name); cf.setDebugMode(this.debugMode); @@ -134,6 +138,7 @@ public class AlarmRuleDefinition extends BaseData implements def.setCreatedTime(cf.getCreatedTime()); def.setTenantId(cf.getTenantId()); def.setEntityId(cf.getEntityId()); + def.setEnabled(cf.isEnabled()); def.setName(cf.getName()); def.setDebugMode(cf.isDebugMode()); def.setDebugSettings(cf.getDebugSettings()); @@ -153,6 +158,7 @@ public class AlarmRuleDefinition extends BaseData implements .append("AlarmRuleDefinition[") .append("tenantId=").append(tenantId) .append(", entityId=").append(entityId) + .append(", enabled=").append(enabled) .append(", name='").append(name) .append(", configurationVersion=").append(configurationVersion) .append(", configuration=").append(configuration) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index 2d3928ee88..f537fc7496 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -67,6 +67,8 @@ public class CalculatedField extends BaseData implements HasN private TenantId tenantId; private EntityId entityId; + @Schema(description = "Enable/disable the calculated field.", example = "true") + private boolean enabled = true; @NoXss @Length(fieldName = "type") @@ -101,6 +103,7 @@ public class CalculatedField extends BaseData implements HasN super(calculatedField); this.tenantId = calculatedField.tenantId; this.entityId = calculatedField.entityId; + this.enabled = calculatedField.enabled; this.type = calculatedField.type; this.name = calculatedField.name; this.debugMode = calculatedField.debugMode; @@ -141,6 +144,7 @@ public class CalculatedField extends BaseData implements HasN .append("CalculatedField[") .append("tenantId=").append(tenantId) .append(", entityId=").append(entityId) + .append(", enabled=").append(enabled) .append(", type='").append(type) .append(", name='").append(name) .append(", configurationVersion=").append(configurationVersion) diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java new file mode 100644 index 0000000000..2e86af826e --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java @@ -0,0 +1,70 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class CalculatedFieldEnabledDeserializationTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + /* + * The 'enabled' field was added together with the enable/disable feature. Calculated fields and alarm rules + * exported before this change have no 'enabled' property in their JSON. Importing such JSON must keep them + * enabled (backward compatible), which relies on the 'enabled = true' field initializer being preserved by + * Jackson when the property is absent. + */ + + @Test + public void givenCalculatedFieldJsonWithoutEnabled_whenDeserialized_thenEnabledIsTrue() throws Exception { + String json = "{\"name\":\"Test CF\",\"type\":\"SIMPLE\",\"configurationVersion\":1}"; + + CalculatedField cf = mapper.readValue(json, CalculatedField.class); + + assertThat(cf.isEnabled()).isTrue(); + } + + @Test + public void givenCalculatedFieldJsonWithEnabledFalse_whenDeserialized_thenEnabledIsFalse() throws Exception { + String json = "{\"name\":\"Test CF\",\"type\":\"SIMPLE\",\"enabled\":false}"; + + CalculatedField cf = mapper.readValue(json, CalculatedField.class); + + assertThat(cf.isEnabled()).isFalse(); + } + + @Test + public void givenAlarmRuleDefinitionJsonWithoutEnabled_whenDeserialized_thenEnabledIsTrue() throws Exception { + String json = "{\"name\":\"Test Alarm Rule\",\"configurationVersion\":1}"; + + AlarmRuleDefinition alarmRule = mapper.readValue(json, AlarmRuleDefinition.class); + + assertThat(alarmRule.isEnabled()).isTrue(); + } + + @Test + public void givenAlarmRuleDefinitionJsonWithEnabledFalse_whenDeserialized_thenEnabledIsFalse() throws Exception { + String json = "{\"name\":\"Test Alarm Rule\",\"enabled\":false}"; + + AlarmRuleDefinition alarmRule = mapper.readValue(json, AlarmRuleDefinition.class); + + assertThat(alarmRule.isEnabled()).isFalse(); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index 075e2d8a9d..2eab63bdc1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -161,6 +161,14 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return calculatedFieldDao.findAllByTenantId(tenantId, pageLink); } + @Override + public PageData findCalculatedFieldsByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink) { + log.trace("Executing findCalculatedFieldsByTenantIdAndEnabled, tenantId [{}], enabled [{}], pageLink [{}]", tenantId, enabled, pageLink); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validatePageLink(pageLink); + return calculatedFieldDao.findAllByTenantIdAndEnabled(tenantId, enabled, pageLink); + } + @Override public PageData findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink) { PageData calculatedFields = calculatedFieldDao.findByTenantIdAndFilter(tenantId, filter, pageLink); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java index 8da1697c1f..a0991d0869 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java @@ -44,6 +44,8 @@ public interface CalculatedFieldDao extends Dao { PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); + PageData findAllByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink); + PageData findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set types, PageLink pageLink); List removeAllByEntityId(TenantId tenantId, EntityId entityId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index ed34a2e64c..d69fd799a4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -732,6 +732,7 @@ public class ModelConstants { public static final String CALCULATED_FIELD_CONFIGURATION = "configuration"; public static final String CALCULATED_FIELD_VERSION = "version"; public static final String CALCULATED_FIELD_ADDITIONAL_INFO = ADDITIONAL_INFO_PROPERTY; + public static final String CALCULATED_FIELD_ENABLED = "enabled"; /** * Tasks constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java index 47c1520ac0..e7fd204295 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java @@ -46,6 +46,7 @@ import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_T import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TENANT_ID_COLUMN; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_TYPE; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_VERSION; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ENABLED; import static org.thingsboard.server.dao.model.ModelConstants.DEBUG_SETTINGS; @Data @@ -63,6 +64,9 @@ public class CalculatedFieldEntity extends BaseVersionedEntity @Column(name = CALCULATED_FIELD_ENTITY_ID) private UUID entityId; + @Column(name = CALCULATED_FIELD_ENABLED) + private boolean enabled; + @Column(name = CALCULATED_FIELD_TYPE) private String type; @@ -94,6 +98,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity this.tenantId = calculatedField.getTenantId().getId(); this.entityType = calculatedField.getEntityId().getEntityType().name(); this.entityId = calculatedField.getEntityId().getId(); + this.enabled = calculatedField.isEnabled(); this.type = calculatedField.getType().name(); this.name = calculatedField.getName(); this.configurationVersion = calculatedField.getConfigurationVersion(); @@ -109,6 +114,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity calculatedField.setCreatedTime(createdTime); calculatedField.setTenantId(TenantId.fromUUID(tenantId)); calculatedField.setEntityId(EntityIdFactory.getByTypeAndUuid(entityType, entityId)); + calculatedField.setEnabled(enabled); calculatedField.setType(CalculatedFieldType.valueOf(type)); calculatedField.setName(name); calculatedField.setConfigurationVersion(configurationVersion); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java index b7b94c5b8c..751d06b9ca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java @@ -38,6 +38,8 @@ public interface CalculatedFieldRepository extends JpaRepository findAllByTenantId(UUID tenantId, Pageable pageable); + Page findAllByTenantIdAndEnabled(UUID tenantId, boolean enabled, Pageable pageable); + @Query("SELECT cf FROM CalculatedFieldEntity cf WHERE cf.tenantId = :tenantId " + "AND cf.entityId = :entityId AND cf.type IN :types " + "AND (:textSearch IS NULL OR ilike(cf.name, CONCAT('%', :textSearch, '%')) = true)") diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java index 0f9dcc20be..c654609f6a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -44,8 +44,9 @@ import java.util.stream.Collectors; @Slf4j public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedFieldRepository { - private final String CF_COUNT_QUERY = "SELECT count(id) FROM calculated_field;"; - private final String CF_QUERY = "SELECT * FROM calculated_field ORDER BY created_time ASC LIMIT %s OFFSET %s"; + // Only enabled calculated fields are loaded: this query feeds the processing cache, and disabled fields must not be processed. + private final String CF_COUNT_QUERY = "SELECT count(id) FROM calculated_field WHERE enabled = true;"; + private final String CF_QUERY = "SELECT * FROM calculated_field WHERE enabled = true ORDER BY created_time ASC LIMIT %s OFFSET %s"; private final NamedParameterJdbcTemplate jdbcTemplate; private final TransactionTemplate transactionTemplate; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java index a374531a47..5cd5de356b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java @@ -87,6 +87,12 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao findAllByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink) { + log.debug("Try to find calculated fields by tenantId[{}], enabled [{}] and pageLink [{}]", tenantId, enabled, pageLink); + return DaoUtil.toPageData(calculatedFieldRepository.findAllByTenantIdAndEnabled(tenantId.getId(), enabled, DaoUtil.toPageable(pageLink))); + } + @Override public PageData findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set types, PageLink pageLink) { log.debug("Try to find calculated fields by entityId [{}] and type [{}] and pageLink [{}]", entityId, types, pageLink); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index c6d9546bad..03aee32882 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -932,6 +932,7 @@ CREATE TABLE IF NOT EXISTS calculated_field ( entity_id uuid NOT NULL, type varchar(32) NOT NULL, name varchar(255) NOT NULL, + enabled boolean NOT NULL DEFAULT true, configuration_version int DEFAULT 0, configuration varchar(1000000), version BIGINT DEFAULT 1, diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index af4197d6ec..8b1a6158b0 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -854,6 +854,125 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.deleteAsset(asset2.getId()); } + @Test + public void testDisableCalculatedField() { + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + waitInitialTelemetry(device.getId(), "temperature"); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId()); + + await().alias("create CF -> perform initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + // Disable the CF + savedCalculatedField.setEnabled(false); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // Send new telemetry - should NOT be processed + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":40}")); + + // Wait a bit and verify the value did NOT change (still 77.0, not 104.0) + await().alias("disabled CF -> telemetry not processed").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + + @Test + public void testEnableDisableCycles() { + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25}")); + waitInitialTelemetry(device.getId(), "temperature"); + + CalculatedField savedCalculatedField = createSimpleCalculatedField(device.getId()); + + // Cycle 1: enabled -> verify calculation + await().alias("cycle 1: CF enabled -> initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + // Cycle 1: disable + savedCalculatedField.setEnabled(false); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // Cycle 1: send telemetry while disabled + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":30}")); + + await().alias("cycle 1: disabled -> no recalculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("77.0"); + }); + + // Cycle 2: re-enable + savedCalculatedField.setEnabled(true); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // After re-enable, CF reinitializes and recalculates with current telemetry (temperature=30) + await().alias("cycle 2: re-enabled -> recalculation with latest telemetry").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + + // Cycle 2: disable again + savedCalculatedField.setEnabled(false); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // Cycle 2: send telemetry while disabled + testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":50}")); + + await().alias("cycle 2: disabled again -> no recalculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .during(10, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("86.0"); + }); + + // Cycle 3: re-enable again + savedCalculatedField.setEnabled(true); + savedCalculatedField = testRestClient.postCalculatedField(savedCalculatedField); + + // After re-enable, recalculates with latest telemetry (temperature=50) + await().alias("cycle 3: re-enabled again -> recalculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + JsonNode fahrenheitTemp = testRestClient.getLatestTelemetry(device.getId()); + assertThat(fahrenheitTemp).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp")).isNotNull(); + assertThat(fahrenheitTemp.get("fahrenheitTemp").get(0).get("value").asText()).isEqualTo("122.0"); + }); + + testRestClient.deleteCalculatedFieldIfExists(savedCalculatedField.getId()); + } + private void waitForDebugEvent(CalculatedField cf, String expectedEventType, int expectedTotalEvents) { await().alias("wait for debug event: " + expectedEventType) .atMost(TIMEOUT, TimeUnit.SECONDS) diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index 9e95a55938..2fc592e316 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -180,6 +180,15 @@ export class AlarmRulesTableConfig extends EntityTableConfig checkBoxCell(!!entity.configuration.clearRule), ()=> { return {padding: 0, textAlign: 'center'}}, false)); this.cellActionDescriptors.push( + { + name: '', + nameFunction: (entity) => + this.translate.instant(entity.enabled ? 'calculated-fields.disable' : 'calculated-fields.enable'), + icon: 'mdi:toggle-switch', + isEnabled: () => true, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnabled($event, entity), + }, { name: this.translate.instant('alarm-rule.copy'), icon: 'content_copy', @@ -390,6 +399,20 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.alarmRulesService.saveAlarmRule({...rule, enabled: !rule.enabled}, {ignoreLoading: true})), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe((saved) => { + if (saved) { + entity.enabled = saved.enabled; + this.getTable().detectChanges(); + } + }); + } + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { this.alarmRulesService.getAlarmRuleById(id).pipe( switchMap(field => this.alarmRulesService.saveAlarmRule({ ...field, debugSettings })), diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index 8d99028163..ac20733b6b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -170,6 +170,15 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig('type', 'common.type', this.pageMode ? '23%' : '40%', entity => this.translate.instant(CalculatedFieldTypeTranslations.get(entity.type).name), () => ({whiteSpace: 'nowrap' }))); this.cellActionDescriptors.push( + { + name: '', + nameFunction: (entity) => + this.translate.instant(entity.enabled ? 'calculated-fields.disable' : 'calculated-fields.enable'), + icon: 'mdi:toggle-switch', + isEnabled: () => true, + iconFunction: (entity) => entity.enabled ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off-outline', + onAction: ($event, entity) => this.toggleEnabled($event, entity), + }, { name: this.translate.instant('action.copy'), icon: 'content_copy', @@ -395,6 +404,20 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig this.calculatedFieldsService.saveCalculatedField({...cf, enabled: !cf.enabled}, {ignoreLoading: true})), + catchError(() => of(null)), + takeUntilDestroyed(this.destroyRef), + ).subscribe((saved) => { + if (saved) { + entity.enabled = saved.enabled; + this.getTable().detectChanges(); + } + }); + } + private onDebugConfigChanged(id: string, debugSettings: EntityDebugSettings): void { this.calculatedFieldsService.getCalculatedFieldById(id).pipe( switchMap(field => this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 552d2a3a95..87aa13873e 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -38,6 +38,7 @@ export const FORBIDDEN_NAMES = ['ctx', 'e', 'pi']; interface BaseCalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { entityId: EntityId; + enabled: boolean; additionalInfo?: any; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 1007fa3eea..e726d99bc0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1161,6 +1161,8 @@ "timeseries": "Time series", "output": "Output", "output-hint": "Defines how output is processed.", + "enable": "Enable", + "disable": "Disable", "create": "Create new calculated field", "file": "Calculated field file", "invalid-file-error": "Invalid file format. Please make sure the file is a valid JSON file.",