Browse Source

Merge 3742020f6d into d4329f3b7c

pull/15382/merge
Oleksandra Matviienko 2 days ago
committed by GitHub
parent
commit
ffbfd41abe
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      application/src/main/data/upgrade/basic/schema_update.sql
  2. 9
      application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java
  3. 4
      application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java
  4. 155
      application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java
  5. 293
      application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java
  6. 116
      application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java
  7. 2
      common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java
  8. 6
      common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java
  9. 4
      common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java
  10. 70
      common/data/src/test/java/org/thingsboard/server/common/data/cf/CalculatedFieldEnabledDeserializationTest.java
  11. 8
      dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java
  12. 2
      dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java
  13. 1
      dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
  14. 6
      dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java
  15. 2
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java
  16. 5
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java
  17. 6
      dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java
  18. 1
      dao/src/main/resources/sql/schema-entities.sql
  19. 119
      msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java
  20. 23
      ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts
  21. 23
      ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts
  22. 1
      ui-ngx/src/app/shared/models/calculated-field.models.ts
  23. 2
      ui-ngx/src/assets/locale/locale.constant-en_US.json

6
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);

9
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<CalculatedField> cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantId(tenantId, pageLink), cfSettings.getInitTenantFetchPackSize());
PageDataIterable<CalculatedField> cfs = new PageDataIterable<>(pageLink -> cfDaoService.findCalculatedFieldsByTenantIdAndEnabled(tenantId, true, pageLink), cfSettings.getInitTenantFetchPackSize());
cfs.forEach(cf -> {
log.trace("Processing calculated field record: {}", cf);
try {

4
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);

155
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<String, Argument> arguments = Map.of("temperature", temperatureArgument);
Map<AlarmSeverity, Condition> 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<String, Argument> arguments = Map.of("temperature", temperatureArgument);
Map<AlarmSeverity, Condition> 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<String, Argument> arguments = Map.of("temperature", temperatureArgument);
Map<AlarmSeverity, Condition> 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<TbAlarmResult> assertion) {

293
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);
}

116
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");

2
common/dao-api/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldService.java

@ -46,6 +46,8 @@ public interface CalculatedFieldService extends EntityDaoService {
PageData<CalculatedField> findCalculatedFieldsByTenantId(TenantId tenantId, PageLink pageLink);
PageData<CalculatedField> findCalculatedFieldsByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink);
PageData<CalculatedFieldInfo> findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink);
PageData<String> findCalculatedFieldNamesByTenantIdAndType(TenantId tenantId, CalculatedFieldType type, PageLink pageLink);

6
common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java

@ -44,6 +44,8 @@ public class AlarmRuleDefinition extends BaseData<CalculatedFieldId> 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<CalculatedFieldId> 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<CalculatedFieldId> 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<CalculatedFieldId> 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<CalculatedFieldId> 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)

4
common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java

@ -67,6 +67,8 @@ public class CalculatedField extends BaseData<CalculatedFieldId> 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<CalculatedFieldId> 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<CalculatedFieldId> 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)

70
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();
}
}

8
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<CalculatedField> 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<CalculatedFieldInfo> findCalculatedFieldsByTenantIdAndFilter(TenantId tenantId, CalculatedFieldFilter filter, PageLink pageLink) {
PageData<CalculatedField> calculatedFields = calculatedFieldDao.findByTenantIdAndFilter(tenantId, filter, pageLink);

2
dao/src/main/java/org/thingsboard/server/dao/cf/CalculatedFieldDao.java

@ -44,6 +44,8 @@ public interface CalculatedFieldDao extends Dao<CalculatedField> {
PageData<CalculatedField> findAllByTenantId(TenantId tenantId, PageLink pageLink);
PageData<CalculatedField> findAllByTenantIdAndEnabled(TenantId tenantId, boolean enabled, PageLink pageLink);
PageData<CalculatedField> findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set<CalculatedFieldType> types, PageLink pageLink);
List<CalculatedField> removeAllByEntityId(TenantId tenantId, EntityId entityId);

1
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.

6
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<CalculatedField>
@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<CalculatedField>
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>
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);

2
dao/src/main/java/org/thingsboard/server/dao/sql/cf/CalculatedFieldRepository.java

@ -38,6 +38,8 @@ public interface CalculatedFieldRepository extends JpaRepository<CalculatedField
Page<CalculatedFieldEntity> findAllByTenantId(UUID tenantId, Pageable pageable);
Page<CalculatedFieldEntity> 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)")

5
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;

6
dao/src/main/java/org/thingsboard/server/dao/sql/cf/JpaCalculatedFieldDao.java

@ -87,6 +87,12 @@ public class JpaCalculatedFieldDao extends JpaAbstractDao<CalculatedFieldEntity,
return DaoUtil.toPageData(calculatedFieldRepository.findAllByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink)));
}
@Override
public PageData<CalculatedField> 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<CalculatedField> findByEntityIdAndTypes(TenantId tenantId, EntityId entityId, Set<CalculatedFieldType> types, PageLink pageLink) {
log.debug("Try to find calculated fields by entityId [{}] and type [{}] and pageLink [{}]", entityId, types, pageLink);

1
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,

119
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)

23
ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts

@ -180,6 +180,15 @@ export class AlarmRulesTableConfig extends EntityTableConfig<AlarmRuleTableEntit
entity => 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<AlarmRuleTableEntit
return calculatedField;
}
private toggleEnabled($event: Event, entity: AlarmRuleTableEntity): void {
$event?.stopPropagation();
this.alarmRulesService.getAlarmRuleById(entity.id.id).pipe(
switchMap(rule => 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 })),

23
ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts

@ -170,6 +170,15 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig<CalculatedFie
this.columns.push(new EntityTableColumn<CalculatedField>('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<CalculatedFie
return calculatedField;
}
private toggleEnabled($event: Event, entity: CalculatedFieldsTableEntity): void {
$event?.stopPropagation();
this.calculatedFieldsService.getCalculatedFieldById(entity.id.id).pipe(
switchMap(cf => 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 })),

1
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<BaseData<CalculatedFieldId>, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity<CalculatedFieldId> {
entityId: EntityId;
enabled: boolean;
additionalInfo?: any;
}

2
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.",

Loading…
Cancel
Save