359 changed files with 7490 additions and 2542 deletions
@ -0,0 +1,25 @@ |
|||
-- |
|||
-- 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. |
|||
-- |
|||
|
|||
-- LTS cumulative schema update file. |
|||
-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.). |
|||
-- This file is executed by SystemPatchApplier on every version increase within the LTS family. |
|||
|
|||
-- CALCULATED FIELD ADDITIONAL INFO ADDITION START |
|||
|
|||
ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; |
|||
|
|||
-- CALCULATED FIELD ADDITIONAL INFO ADDITION END |
|||
@ -0,0 +1,51 @@ |
|||
/** |
|||
* 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.config; |
|||
|
|||
import org.springframework.beans.factory.annotation.Value; |
|||
import org.springframework.stereotype.Component; |
|||
import org.thingsboard.rule.engine.api.TbHttpClientSettings; |
|||
import org.thingsboard.server.queue.util.TbRuleEngineComponent; |
|||
|
|||
@TbRuleEngineComponent |
|||
@Component |
|||
public class TbHttpClientSettingsComponent implements TbHttpClientSettings { |
|||
|
|||
@Value("${actors.rule.external.http_client.max_parallel_requests:0}") |
|||
private int maxParallelRequests; |
|||
|
|||
@Value("${actors.rule.external.http_client.max_pending_requests:0}") |
|||
private int maxPendingRequests; |
|||
|
|||
@Value("${actors.rule.external.http_client.pool_max_connections:0}") |
|||
private int poolMaxConnections; |
|||
|
|||
@Override |
|||
public int getMaxParallelRequests() { |
|||
return maxParallelRequests; |
|||
} |
|||
|
|||
@Override |
|||
public int getMaxPendingRequests() { |
|||
return maxPendingRequests; |
|||
} |
|||
|
|||
@Override |
|||
public int getPoolMaxConnections() { |
|||
return poolMaxConnections; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,259 @@ |
|||
/** |
|||
* 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.controller; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import io.swagger.v3.oas.annotations.Parameter; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.RequiredArgsConstructor; |
|||
import org.springframework.http.HttpStatus; |
|||
import org.springframework.security.access.prepost.PreAuthorize; |
|||
import org.springframework.web.bind.annotation.DeleteMapping; |
|||
import org.springframework.web.bind.annotation.GetMapping; |
|||
import org.springframework.web.bind.annotation.PathVariable; |
|||
import org.springframework.web.bind.annotation.PostMapping; |
|||
import org.springframework.web.bind.annotation.RequestBody; |
|||
import org.springframework.web.bind.annotation.RequestMapping; |
|||
import org.springframework.web.bind.annotation.RequestParam; |
|||
import org.springframework.web.bind.annotation.ResponseStatus; |
|||
import org.springframework.web.bind.annotation.RestController; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.EventInfo; |
|||
import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; |
|||
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; |
|||
import org.thingsboard.server.common.data.cf.AlarmRuleDefinitionInfo; |
|||
import org.thingsboard.server.common.data.cf.CalculatedField; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.event.EventType; |
|||
import org.thingsboard.server.common.data.exception.ThingsboardException; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.EntityIdFactory; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.page.PageLink; |
|||
import org.thingsboard.server.config.annotations.ApiOperation; |
|||
import org.thingsboard.server.dao.event.EventService; |
|||
import org.thingsboard.server.queue.util.TbCoreComponent; |
|||
import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; |
|||
import org.thingsboard.server.service.security.model.SecurityUser; |
|||
import org.thingsboard.server.service.security.permission.Operation; |
|||
|
|||
import java.util.EnumSet; |
|||
import java.util.Map; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
import java.util.UUID; |
|||
import java.util.stream.Collectors; |
|||
|
|||
import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; |
|||
import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; |
|||
import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; |
|||
import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; |
|||
import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; |
|||
|
|||
@RestController |
|||
@TbCoreComponent |
|||
@RequestMapping("/api") |
|||
@RequiredArgsConstructor |
|||
public class AlarmRuleController extends BaseController { |
|||
|
|||
private final TbCalculatedFieldService tbCalculatedFieldService; |
|||
private final EventService eventService; |
|||
|
|||
public static final String ALARM_RULE_ID = "alarmRuleId"; |
|||
|
|||
private static final String TEST_SCRIPT_EXPRESSION = |
|||
"Execute the alarm rule TBEL condition expression and return the result. " + |
|||
"Alarm rule expressions must return a boolean value. The format of request: \n\n" |
|||
+ MARKDOWN_CODE_BLOCK_START |
|||
+ "{\n" + |
|||
" \"expression\": \"return temperature > 50;\",\n" + |
|||
" \"arguments\": {\n" + |
|||
" \"temperature\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 55 }\n" + |
|||
" }\n" + |
|||
"}" |
|||
+ MARKDOWN_CODE_BLOCK_END |
|||
+ "\n\n Expected result JSON contains \"output\" and \"error\"."; |
|||
|
|||
@ApiOperation(value = "Create Or Update Alarm Rule (saveAlarmRule)", |
|||
notes = "Creates or Updates the Alarm Rule. When creating alarm rule, platform generates Alarm Rule Id as " + UUID_WIKI_LINK + |
|||
"The newly created Alarm Rule Id will be present in the response. " + |
|||
"Specify existing Alarm Rule Id to update the alarm rule. " + |
|||
"Referencing non-existing Alarm Rule Id will cause 'Not Found' error. " + |
|||
"Remove 'id', 'tenantId' from the request body example (below) to create new Alarm Rule entity. " |
|||
+ TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@PostMapping("/alarm/rule") |
|||
public AlarmRuleDefinition saveAlarmRule(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the alarm rule.") |
|||
@RequestBody AlarmRuleDefinition alarmRuleDefinition) throws Exception { |
|||
alarmRuleDefinition.setTenantId(getTenantId()); |
|||
checkEntityId(alarmRuleDefinition.getEntityId(), Operation.WRITE_CALCULATED_FIELD); |
|||
if (alarmRuleDefinition.getId() != null) { |
|||
checkAlarmRule(alarmRuleDefinition.getId()); |
|||
} |
|||
CalculatedField calculatedField = alarmRuleDefinition.toCalculatedField(); |
|||
checkReferencedEntities(calculatedField.getConfiguration()); |
|||
CalculatedField saved = tbCalculatedFieldService.save(calculatedField, getCurrentUser()); |
|||
return AlarmRuleDefinition.fromCalculatedField(saved); |
|||
} |
|||
|
|||
@ApiOperation(value = "Get Alarm Rule (getAlarmRuleById)", |
|||
notes = "Fetch the Alarm Rule object based on the provided Alarm Rule Id." + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@GetMapping("/alarm/rule/{alarmRuleId}") |
|||
public AlarmRuleDefinition getAlarmRuleById(@Parameter @PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws ThingsboardException { |
|||
checkParameter(ALARM_RULE_ID, strAlarmRuleId); |
|||
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId)); |
|||
CalculatedField calculatedField = checkAlarmRule(calculatedFieldId); |
|||
checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); |
|||
return AlarmRuleDefinition.fromCalculatedField(calculatedField); |
|||
} |
|||
|
|||
@ApiOperation(value = "Get Alarm Rules by Entity Id (getAlarmRulesByEntityId)", |
|||
notes = "Fetch the Alarm Rules based on the provided Entity Id." + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@GetMapping(value = "/alarm/rules/{entityType}/{entityId}") |
|||
public PageData<AlarmRuleDefinition> getAlarmRulesByEntityId( |
|||
@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, |
|||
@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, |
|||
@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, |
|||
@Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, |
|||
@Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, |
|||
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, |
|||
@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { |
|||
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); |
|||
checkParameter("entityId", entityIdStr); |
|||
EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); |
|||
checkEntityId(entityId, Operation.READ_CALCULATED_FIELD); |
|||
PageData<CalculatedField> result = checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, CalculatedFieldType.ALARM, pageLink)); |
|||
return result.mapData(AlarmRuleDefinition::fromCalculatedField); |
|||
} |
|||
|
|||
@ApiOperation(value = "Get alarm rules (getAlarmRules)", |
|||
notes = "Fetch tenant alarm rules based on the filter." + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@GetMapping(value = "/alarm/rules") |
|||
public PageData<AlarmRuleDefinitionInfo> getAlarmRules(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) |
|||
@RequestParam int pageSize, |
|||
@Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) |
|||
@RequestParam int page, |
|||
@Parameter(description = "Entity type filter. If not specified, alarm rules for all supported entity types will be returned.") |
|||
@RequestParam(required = false) EntityType entityType, |
|||
@Parameter(description = "Entities filter. If not specified, alarm rules for entity type filter will be returned.") |
|||
@RequestParam(required = false) Set<UUID> entities, |
|||
@Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) |
|||
@RequestParam(required = false) String textSearch, |
|||
@Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) |
|||
@RequestParam(required = false) String sortProperty, |
|||
@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) |
|||
@RequestParam(required = false) String sortOrder) throws ThingsboardException { |
|||
PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); |
|||
SecurityUser user = getCurrentUser(); |
|||
|
|||
Set<EntityType> entityTypes; |
|||
if (entityType == null) { |
|||
entityTypes = CalculatedField.SUPPORTED_ENTITIES.entrySet().stream() |
|||
.filter(entry -> entry.getValue().contains(CalculatedFieldType.ALARM)) |
|||
.map(Map.Entry::getKey) |
|||
.collect(Collectors.toSet()); |
|||
} else { |
|||
entityTypes = EnumSet.of(entityType); |
|||
} |
|||
|
|||
CalculatedFieldFilter filter = CalculatedFieldFilter.builder() |
|||
.types(EnumSet.of(CalculatedFieldType.ALARM)) |
|||
.entityTypes(entityTypes) |
|||
.entityIds(entities) |
|||
.build(); |
|||
PageData<CalculatedFieldInfo> result = calculatedFieldService.findCalculatedFieldsByTenantIdAndFilter(user.getTenantId(), filter, pageLink); |
|||
return result.mapData(AlarmRuleDefinitionInfo::fromCalculatedFieldInfo); |
|||
} |
|||
|
|||
@ApiOperation(value = "Get alarm rule names (getAlarmRuleNames)", |
|||
notes = "Fetch the list of alarm rule names." + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@GetMapping(value = "/alarm/rules/names") |
|||
public PageData<String> getAlarmRuleNames(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) |
|||
@RequestParam int pageSize, |
|||
@Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) |
|||
@RequestParam int page, |
|||
@Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) |
|||
@RequestParam(required = false) String textSearch, |
|||
@Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) |
|||
@RequestParam(required = false) String sortOrder) throws ThingsboardException { |
|||
PageLink pageLink = createPageLink(pageSize, page, textSearch, "name", sortOrder); |
|||
return calculatedFieldService.findCalculatedFieldNamesByTenantIdAndType(getTenantId(), CalculatedFieldType.ALARM, pageLink); |
|||
} |
|||
|
|||
@ApiOperation(value = "Delete Alarm Rule (deleteAlarmRule)", |
|||
notes = "Deletes the alarm rule. Referencing non-existing Alarm Rule Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@DeleteMapping("/alarm/rule/{alarmRuleId}") |
|||
@ResponseStatus(HttpStatus.OK) |
|||
public void deleteAlarmRule(@PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws Exception { |
|||
checkParameter(ALARM_RULE_ID, strAlarmRuleId); |
|||
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId)); |
|||
CalculatedField calculatedField = checkAlarmRule(calculatedFieldId); |
|||
checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); |
|||
tbCalculatedFieldService.delete(calculatedField, getCurrentUser()); |
|||
} |
|||
|
|||
@ApiOperation(value = "Get latest alarm rule debug event (getLatestAlarmRuleDebugEvent)", |
|||
notes = "Gets latest alarm rule debug event for specified alarm rule id. " + |
|||
"Referencing non-existing alarm rule id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@GetMapping("/alarm/rule/{alarmRuleId}/debug") |
|||
public JsonNode getLatestAlarmRuleDebugEvent(@Parameter @PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws ThingsboardException { |
|||
checkParameter(ALARM_RULE_ID, strAlarmRuleId); |
|||
CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId)); |
|||
CalculatedField calculatedField = checkAlarmRule(calculatedFieldId); |
|||
checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); |
|||
TenantId tenantId = getCurrentUser().getTenantId(); |
|||
return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1)) |
|||
.flatMap(events -> events.stream().map(EventInfo::getBody).findFirst()) |
|||
.orElse(null); |
|||
} |
|||
|
|||
@ApiOperation(value = "Test alarm rule TBEL expression (testAlarmRuleScript)", |
|||
notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) |
|||
@PreAuthorize("hasAuthority('TENANT_ADMIN')") |
|||
@PostMapping("/alarm/rule/testScript") |
|||
public JsonNode testAlarmRuleScript( |
|||
@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test alarm rule TBEL condition expression. The expression must return a boolean value.") |
|||
@RequestBody JsonNode inputParams) throws ThingsboardException { |
|||
checkParameter("expression", inputParams.has("expression") ? inputParams.get("expression").asText() : null); |
|||
return tbCalculatedFieldService.executeTestScript(getTenantId(), inputParams); |
|||
} |
|||
|
|||
private CalculatedField checkAlarmRule(CalculatedFieldId calculatedFieldId) throws ThingsboardException { |
|||
CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser()); |
|||
checkNotNull(calculatedField); |
|||
if (calculatedField.getType() != CalculatedFieldType.ALARM) { |
|||
throw new ThingsboardException("Alarm rule not found", ThingsboardErrorCode.ITEM_NOT_FOUND); |
|||
} |
|||
return calculatedField; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,420 @@ |
|||
/** |
|||
* 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.controller; |
|||
|
|||
import com.fasterxml.jackson.core.type.TypeReference; |
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import org.junit.After; |
|||
import org.junit.Before; |
|||
import org.junit.Test; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.DeviceProfile; |
|||
import org.thingsboard.server.common.data.EntityType; |
|||
import org.thingsboard.server.common.data.Tenant; |
|||
import org.thingsboard.server.common.data.User; |
|||
import org.thingsboard.server.common.data.alarm.AlarmSeverity; |
|||
import org.thingsboard.server.common.data.alarm.rule.AlarmRule; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; |
|||
import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; |
|||
import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; |
|||
import org.thingsboard.server.common.data.cf.AlarmRuleDefinitionInfo; |
|||
import org.thingsboard.server.common.data.cf.CalculatedField; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.cf.configuration.Argument; |
|||
import org.thingsboard.server.common.data.cf.configuration.ArgumentType; |
|||
import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; |
|||
import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.page.PageLink; |
|||
import org.thingsboard.server.common.data.page.SortOrder; |
|||
import org.thingsboard.server.common.data.security.Authority; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
|
|||
import java.util.Comparator; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
@DaoSqlTest |
|||
public class AlarmRuleControllerTest extends AbstractControllerTest { |
|||
|
|||
private Tenant savedTenant; |
|||
|
|||
@Before |
|||
public void beforeTest() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
Tenant tenant = new Tenant(); |
|||
tenant.setTitle("My tenant"); |
|||
savedTenant = saveTenant(tenant); |
|||
assertThat(savedTenant).isNotNull(); |
|||
|
|||
User tenantAdmin = new User(); |
|||
tenantAdmin.setAuthority(Authority.TENANT_ADMIN); |
|||
tenantAdmin.setTenantId(savedTenant.getId()); |
|||
tenantAdmin.setEmail("tenant2@thingsboard.org"); |
|||
tenantAdmin.setFirstName("Joe"); |
|||
tenantAdmin.setLastName("Downs"); |
|||
|
|||
createUserAndLogin(tenantAdmin, "testPassword1"); |
|||
} |
|||
|
|||
@After |
|||
public void afterTest() throws Exception { |
|||
loginSysAdmin(); |
|||
deleteTenant(savedTenant.getId()); |
|||
} |
|||
|
|||
@Test |
|||
public void testSaveAlarmRule() throws Exception { |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
AlarmRuleDefinition alarmRule = createTestAlarmRule(testDevice.getId(), "High Temperature"); |
|||
|
|||
AlarmRuleDefinition saved = saveAlarmRule(alarmRule); |
|||
|
|||
assertThat(saved).isNotNull(); |
|||
assertThat(saved.getId()).isNotNull(); |
|||
assertThat(saved.getCreatedTime()).isGreaterThan(0); |
|||
assertThat(saved.getTenantId()).isEqualTo(savedTenant.getId()); |
|||
assertThat(saved.getEntityId()).isEqualTo(testDevice.getId()); |
|||
assertThat(saved.getName()).isEqualTo("High Temperature"); |
|||
assertThat(saved.getConfiguration()).isNotNull(); |
|||
assertThat(saved.getConfiguration().getCreateRules()).containsKey(AlarmSeverity.CRITICAL); |
|||
|
|||
saved.setName("Updated Alarm Rule"); |
|||
AlarmRuleDefinition updated = saveAlarmRule(saved); |
|||
|
|||
assertThat(updated.getName()).isEqualTo("Updated Alarm Rule"); |
|||
assertThat(updated.getVersion()).isEqualTo(saved.getVersion() + 1); |
|||
|
|||
doDelete("/api/alarm/rule/" + saved.getId().getId()) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRuleById() throws Exception { |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
AlarmRuleDefinition alarmRule = createTestAlarmRule(testDevice.getId(), "Test Alarm"); |
|||
|
|||
AlarmRuleDefinition saved = saveAlarmRule(alarmRule); |
|||
AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + saved.getId().getId(), AlarmRuleDefinition.class); |
|||
|
|||
assertThat(fetched).isNotNull(); |
|||
assertThat(fetched).isEqualTo(saved); |
|||
|
|||
doDelete("/api/alarm/rule/" + saved.getId().getId()) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRuleById_notFound() throws Exception { |
|||
doGet("/api/alarm/rule/" + UUID.randomUUID()) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRuleById_calculatedFieldNotAlarm() throws Exception { |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
CalculatedField cf = createSimpleCalculatedField(testDevice.getId()); |
|||
CalculatedField savedCf = doPost("/api/calculatedField", cf, CalculatedField.class); |
|||
|
|||
doGet("/api/alarm/rule/" + savedCf.getId().getId()) |
|||
.andExpect(status().isNotFound()); |
|||
|
|||
doDelete("/api/calculatedField/" + savedCf.getId().getId()) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRulesByEntityId() throws Exception { |
|||
Device device1 = createDevice("Device 1", "1234567890"); |
|||
Device device2 = createDevice("Device 2", "0987654321"); |
|||
AlarmRuleDefinition rule1 = saveAlarmRule(createTestAlarmRule(device1.getId(), "Rule 1")); |
|||
saveAlarmRule(createTestAlarmRule(device2.getId(), "Rule 2")); |
|||
|
|||
PageData<AlarmRuleDefinition> result = doGetTypedWithPageLink( |
|||
"/api/alarm/rules/" + EntityType.DEVICE + "/" + device1.getUuidId() + "?", |
|||
new TypeReference<>() {}, new PageLink(10)); |
|||
|
|||
assertThat(result.getData()).hasSize(1); |
|||
assertThat(result.getData().get(0).getId()).isEqualTo(rule1.getId()); |
|||
assertThat(result.getData().get(0).getName()).isEqualTo("Rule 1"); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRules() throws Exception { |
|||
Device device = createDevice("Device A", "1234567890"); |
|||
AlarmRuleDefinition deviceRule = saveAlarmRule(createTestAlarmRule(device.getId(), "Device Alarm")); |
|||
|
|||
DeviceProfile profile = doPost("/api/deviceProfile", createDeviceProfile("Profile A"), DeviceProfile.class); |
|||
AlarmRuleDefinition profileRule = saveAlarmRule(createTestAlarmRule(profile.getId(), "Profile Alarm")); |
|||
|
|||
// All alarm rules
|
|||
List<AlarmRuleDefinitionInfo> all = getAlarmRules(null, null); |
|||
assertThat(all).extracting(AlarmRuleDefinition::getName) |
|||
.contains("Device Alarm", "Profile Alarm"); |
|||
|
|||
// Filter by entity type: DEVICE
|
|||
List<AlarmRuleDefinitionInfo> deviceRules = getAlarmRules(EntityType.DEVICE, null); |
|||
assertThat(deviceRules).extracting(AlarmRuleDefinition::getName) |
|||
.containsOnly("Device Alarm"); |
|||
|
|||
// Filter by entity type: DEVICE_PROFILE
|
|||
List<AlarmRuleDefinitionInfo> profileRules = getAlarmRules(EntityType.DEVICE_PROFILE, null); |
|||
assertThat(profileRules).extracting(AlarmRuleDefinition::getName) |
|||
.containsOnly("Profile Alarm"); |
|||
|
|||
// Filter by specific entity IDs
|
|||
List<AlarmRuleDefinitionInfo> specificRules = getAlarmRules(EntityType.DEVICE, List.of(device.getUuidId())); |
|||
assertThat(specificRules).extracting(AlarmRuleDefinition::getName) |
|||
.containsOnly("Device Alarm"); |
|||
|
|||
// Verify entity names are populated
|
|||
AlarmRuleDefinitionInfo deviceInfo = all.stream() |
|||
.filter(r -> r.getName().equals("Device Alarm")).findFirst().orElseThrow(); |
|||
assertThat(deviceInfo.getEntityName()).isEqualTo("Device A"); |
|||
|
|||
AlarmRuleDefinitionInfo profileInfo = all.stream() |
|||
.filter(r -> r.getName().equals("Profile Alarm")).findFirst().orElseThrow(); |
|||
assertThat(profileInfo.getEntityName()).isEqualTo("Profile A"); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRules_textSearch() throws Exception { |
|||
Device device = createDevice("Device A", "1234567890"); |
|||
saveAlarmRule(createTestAlarmRule(device.getId(), "Temperature Alarm")); |
|||
saveAlarmRule(createTestAlarmRule(device.getId(), "Humidity Alarm")); |
|||
|
|||
PageData<AlarmRuleDefinitionInfo> result = doGetTypedWithPageLink( |
|||
"/api/alarm/rules?textSearch=Temp&", |
|||
new TypeReference<>() {}, new PageLink(10)); |
|||
|
|||
assertThat(result.getData()).hasSize(1); |
|||
assertThat(result.getData().get(0).getName()).isEqualTo("Temperature Alarm"); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetAlarmRuleNames() throws Exception { |
|||
Device device = createDevice("Device A", "1234567890"); |
|||
saveAlarmRule(createTestAlarmRule(device.getId(), "Alpha Alarm")); |
|||
saveAlarmRule(createTestAlarmRule(device.getId(), "Beta Alarm")); |
|||
|
|||
PageData<String> names = getAlarmRuleNames(new PageLink(10, 0, |
|||
null, new SortOrder("", SortOrder.Direction.ASC))); |
|||
assertThat(names.getTotalElements()).isEqualTo(2); |
|||
assertThat(names.getData()).isSortedAccordingTo(Comparator.naturalOrder()); |
|||
assertThat(names.getData()).contains("Alpha Alarm", "Beta Alarm"); |
|||
|
|||
names = getAlarmRuleNames(new PageLink(10, 0, |
|||
null, new SortOrder("", SortOrder.Direction.DESC))); |
|||
assertThat(names.getData()).isSortedAccordingTo(Comparator.reverseOrder()); |
|||
|
|||
names = getAlarmRuleNames(new PageLink(10, 0, |
|||
"Alpha", new SortOrder("", SortOrder.Direction.ASC))); |
|||
assertThat(names.getTotalElements()).isEqualTo(1); |
|||
assertThat(names.getData()).containsOnly("Alpha Alarm"); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteAlarmRule() throws Exception { |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
AlarmRuleDefinition saved = saveAlarmRule(createTestAlarmRule(testDevice.getId(), "To Delete")); |
|||
|
|||
assertThat(saved).isNotNull(); |
|||
|
|||
doDelete("/api/alarm/rule/" + saved.getId().getId()) |
|||
.andExpect(status().isOk()); |
|||
doGet("/api/alarm/rule/" + saved.getId().getId()) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteAlarmRule_notFound() throws Exception { |
|||
doDelete("/api/alarm/rule/" + UUID.randomUUID()) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
@Test |
|||
public void testDeleteAlarmRule_calculatedFieldNotAlarm() throws Exception { |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
CalculatedField cf = createSimpleCalculatedField(testDevice.getId()); |
|||
CalculatedField savedCf = doPost("/api/calculatedField", cf, CalculatedField.class); |
|||
|
|||
doDelete("/api/alarm/rule/" + savedCf.getId().getId()) |
|||
.andExpect(status().isNotFound()); |
|||
|
|||
doDelete("/api/calculatedField/" + savedCf.getId().getId()) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetLatestAlarmRuleDebugEvent() throws Exception { |
|||
Device testDevice = createDevice("Test device", "1234567890"); |
|||
AlarmRuleDefinition saved = saveAlarmRule(createTestAlarmRule(testDevice.getId(), "Debug Test")); |
|||
|
|||
doGet("/api/alarm/rule/" + saved.getId().getId() + "/debug") |
|||
.andExpect(status().isOk()); |
|||
|
|||
doDelete("/api/alarm/rule/" + saved.getId().getId()) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
@Test |
|||
public void testGetLatestAlarmRuleDebugEvent_notFound() throws Exception { |
|||
doGet("/api/alarm/rule/" + UUID.randomUUID() + "/debug") |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
@Test |
|||
public void testTestAlarmRuleScript() throws Exception { |
|||
JsonNode request = JacksonUtil.toJsonNode(""" |
|||
{ |
|||
"expression": "return temperature > 50;", |
|||
"arguments": { |
|||
"temperature": { "type": "SINGLE_VALUE", "ts": 1739776478057, "value": 55 } |
|||
} |
|||
} |
|||
"""); |
|||
|
|||
JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class); |
|||
|
|||
assertThat(result).isNotNull(); |
|||
assertThat(result.has("output")).isTrue(); |
|||
assertThat(result.has("error")).isTrue(); |
|||
assertThat(result.get("error").asText()).isEmpty(); |
|||
assertThat(result.get("output").asText()).isEqualTo("true"); |
|||
} |
|||
|
|||
@Test |
|||
public void testTestAlarmRuleScript_returnsFalse() throws Exception { |
|||
JsonNode request = JacksonUtil.toJsonNode(""" |
|||
{ |
|||
"expression": "return temperature > 50;", |
|||
"arguments": { |
|||
"temperature": { "type": "SINGLE_VALUE", "ts": 1739776478057, "value": 30 } |
|||
} |
|||
} |
|||
"""); |
|||
|
|||
JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class); |
|||
|
|||
assertThat(result).isNotNull(); |
|||
assertThat(result.get("error").asText()).isEmpty(); |
|||
assertThat(result.get("output").asText()).isEqualTo("false"); |
|||
} |
|||
|
|||
@Test |
|||
public void testTestAlarmRuleScript_missingExpression() throws Exception { |
|||
JsonNode request = JacksonUtil.toJsonNode(""" |
|||
{ |
|||
"arguments": {} |
|||
} |
|||
"""); |
|||
|
|||
doPost("/api/alarm/rule/testScript", request) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
|
|||
@Test |
|||
public void testTestAlarmRuleScript_invalidExpression() throws Exception { |
|||
JsonNode request = JacksonUtil.toJsonNode(""" |
|||
{ |
|||
"expression": "invalid syntax {{{{", |
|||
"arguments": {} |
|||
} |
|||
"""); |
|||
|
|||
JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class); |
|||
|
|||
assertThat(result).isNotNull(); |
|||
assertThat(result.get("error").asText()).isNotEmpty(); |
|||
} |
|||
|
|||
// --- Helper methods ---
|
|||
|
|||
private AlarmRuleDefinition createTestAlarmRule(EntityId entityId, String name) { |
|||
AlarmRuleDefinition alarmRule = new AlarmRuleDefinition(); |
|||
alarmRule.setEntityId(entityId); |
|||
alarmRule.setName(name); |
|||
alarmRule.setConfigurationVersion(1); |
|||
alarmRule.setAdditionalInfo(JacksonUtil.newObjectNode()); |
|||
|
|||
AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); |
|||
|
|||
Argument argument = new Argument(); |
|||
argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
argument.setDefaultValue("0"); |
|||
configuration.setArguments(Map.of("temperature", argument)); |
|||
|
|||
AlarmRule rule = new AlarmRule(); |
|||
TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression(); |
|||
expression.setExpression("return temperature >= 50;"); |
|||
SimpleAlarmCondition condition = new SimpleAlarmCondition(); |
|||
condition.setExpression(expression); |
|||
rule.setCondition(condition); |
|||
configuration.setCreateRules(Map.of(AlarmSeverity.CRITICAL, rule)); |
|||
|
|||
alarmRule.setConfiguration(configuration); |
|||
return alarmRule; |
|||
} |
|||
|
|||
private CalculatedField createSimpleCalculatedField(EntityId entityId) { |
|||
CalculatedField cf = new CalculatedField(); |
|||
cf.setEntityId(entityId); |
|||
cf.setType(CalculatedFieldType.SIMPLE); |
|||
cf.setName("Simple CF"); |
|||
cf.setConfigurationVersion(1); |
|||
cf.setAdditionalInfo(JacksonUtil.newObjectNode()); |
|||
|
|||
SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); |
|||
Argument arg = new Argument(); |
|||
arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); |
|||
config.setArguments(Map.of("T", arg)); |
|||
config.setExpression("T * 2"); |
|||
TimeSeriesOutput output = new TimeSeriesOutput(); |
|||
output.setName("result"); |
|||
config.setOutput(output); |
|||
cf.setConfiguration(config); |
|||
|
|||
return cf; |
|||
} |
|||
|
|||
private List<AlarmRuleDefinitionInfo> getAlarmRules(EntityType entityType, List<UUID> entities) throws Exception { |
|||
StringBuilder url = new StringBuilder("/api/alarm/rules?"); |
|||
if (entityType != null) { |
|||
url.append("entityType=").append(entityType).append("&"); |
|||
} |
|||
if (entities != null) { |
|||
url.append("entities=").append(String.join(",", |
|||
entities.stream().map(UUID::toString).toList())).append("&"); |
|||
} |
|||
return doGetTypedWithPageLink(url.toString(), |
|||
new TypeReference<PageData<AlarmRuleDefinitionInfo>>() {}, new PageLink(10)).getData(); |
|||
} |
|||
|
|||
private PageData<String> getAlarmRuleNames(PageLink pageLink) throws Exception { |
|||
return doGetTypedWithPageLink("/api/alarm/rules/names?", |
|||
new TypeReference<PageData<String>>() {}, pageLink); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,517 @@ |
|||
/** |
|||
* 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.service.cf; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.cf.CalculatedField; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldLink; |
|||
import org.thingsboard.server.common.data.cf.CalculatedFieldType; |
|||
import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.id.TenantProfileId; |
|||
import org.thingsboard.server.common.data.page.PageData; |
|||
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; |
|||
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; |
|||
import org.thingsboard.server.dao.asset.AssetService; |
|||
import org.thingsboard.server.dao.cf.CalculatedFieldService; |
|||
import org.thingsboard.server.dao.customer.CustomerService; |
|||
import org.thingsboard.server.dao.device.DeviceService; |
|||
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; |
|||
import org.thingsboard.server.service.profile.TbAssetProfileCache; |
|||
import org.thingsboard.server.service.profile.TbDeviceProfileCache; |
|||
|
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.eq; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.spy; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class DefaultCalculatedFieldCacheTest { |
|||
|
|||
@Mock |
|||
private CalculatedFieldService calculatedFieldService; |
|||
@Mock |
|||
private TbAssetProfileCache assetProfileCache; |
|||
@Mock |
|||
private TbDeviceProfileCache deviceProfileCache; |
|||
@Mock |
|||
private TbTenantProfileCache tenantProfileCache; |
|||
@Mock |
|||
private DeviceService deviceService; |
|||
@Mock |
|||
private AssetService assetService; |
|||
@Mock |
|||
private CustomerService customerService; |
|||
|
|||
private DefaultCalculatedFieldCache cache; |
|||
|
|||
@BeforeEach |
|||
public void setUp() { |
|||
// ActorSystemContext is only used in getCalculatedFieldCtx (not tested here), so null is safe
|
|||
OwnerService ownerService = new OwnerService(deviceService, assetService, customerService); |
|||
cache = new DefaultCalculatedFieldCache(calculatedFieldService, assetProfileCache, |
|||
deviceProfileCache, tenantProfileCache, null, ownerService); |
|||
|
|||
} |
|||
|
|||
// --- Tenant deletion tests ---
|
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_evictsAllTenantCfsFromAllMaps() { |
|||
TenantId tenant1 = new TenantId(UUID.randomUUID()); |
|||
TenantId tenant2 = new TenantId(UUID.randomUUID()); |
|||
DeviceId device1 = new DeviceId(UUID.randomUUID()); |
|||
DeviceId device2 = new DeviceId(UUID.randomUUID()); |
|||
|
|||
CalculatedField cf1 = addCfToCache(tenant1, device1); |
|||
CalculatedField cf2 = addCfToCache(tenant2, device2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf1.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(device1)).isEmpty(); |
|||
assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(device2)).containsExactly(cf2); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_evictsOwnerEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
stubDeviceOwner(tenant, device, tenant); |
|||
|
|||
cache.addOwnerEntity(tenant, device); |
|||
assertThat(cache.getDynamicEntities(tenant, tenant)).contains(device); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// After eviction, getDynamicEntities triggers a fresh load from ownerService (empty)
|
|||
assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(device); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_removesLinksToLinkedEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId cfEntity = new DeviceId(UUID.randomUUID()); |
|||
DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); |
|||
|
|||
CalculatedField cf = addCfToCache(tenant, cfEntity, linkedDevice); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); |
|||
assertThat(cache.getCalculatedField(cf.getId())).isNull(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictCfs() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, device); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); |
|||
} |
|||
|
|||
// --- Device/Asset deletion tests ---
|
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceDeleted_evictsCfsForThatDevice() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, device); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceDeleted_removesLinksForLinkedEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); |
|||
addCfToCache(tenant, device, linkedDevice); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceDeleted_evictsDeviceFromOwnerEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
CustomerId customer = new CustomerId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
stubDeviceOwner(tenant, device, customer); |
|||
|
|||
cache.addOwnerEntity(tenant, device); |
|||
assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_assetDeleted_evictsCfsForThatAsset() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetId asset = new AssetId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, asset); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, asset, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceCreated_addsDeviceToOwnerEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
CustomerId customer = new CustomerId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
stubDeviceOwner(tenant, device, customer); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.CREATED)); |
|||
|
|||
assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); |
|||
} |
|||
|
|||
// --- Customer deletion tests ---
|
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_customerDeleted_evictsCustomerOwnerEntries() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
CustomerId customer = new CustomerId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
stubDeviceOwner(tenant, device, customer); |
|||
|
|||
cache.addOwnerEntity(tenant, device); |
|||
assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// The customer's owned-entities entry is evicted; fresh load returns empty
|
|||
assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); |
|||
} |
|||
|
|||
// --- DeviceProfile/AssetProfile deletion tests ---
|
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); |
|||
DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); |
|||
addCfToCache(tenant, profileId, linkedDevice); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID()); |
|||
DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID()); |
|||
CalculatedField cf1 = addCfToCache(tenant, profile1); |
|||
CalculatedField cf2 = addCfToCache(tenant, profile2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf1.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); |
|||
assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); |
|||
AssetId linkedAsset = new AssetId(UUID.randomUUID()); |
|||
addCfToCache(tenant, profileId, linkedAsset); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID()); |
|||
AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID()); |
|||
CalculatedField cf1 = addCfToCache(tenant, profile1); |
|||
CalculatedField cf2 = addCfToCache(tenant, profile2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf1.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); |
|||
assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); |
|||
} |
|||
|
|||
// --- CalculatedField lifecycle tests ---
|
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_calculatedFieldCreated_addsCfToCache() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); |
|||
CalculatedField cf = buildCalculatedField(cfId, tenant, device, simpleCfConfig()); |
|||
when(calculatedFieldService.findById(tenant, cfId)).thenReturn(cf); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cfId, ComponentLifecycleEvent.CREATED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cfId)).isEqualTo(cf); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(device)).containsExactly(cf); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_calculatedFieldDeleted_evictsCfFromCache() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, device); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isNull(); |
|||
assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_calculatedFieldUpdated_refreshesCfInCache() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
CalculatedField cf = addCfToCache(tenant, device); |
|||
|
|||
CalculatedField updatedCf = buildCalculatedField(cf.getId(), tenant, device, simpleCfConfig()); |
|||
updatedCf.setName("updated-name"); |
|||
when(calculatedFieldService.findById(tenant, cf.getId())).thenReturn(updatedCf); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf); |
|||
} |
|||
|
|||
// --- evictOwner recursive traversal tests ---
|
|||
|
|||
@Test |
|||
public void evictOwner_customerDeleted_recursivelyEvictsDevicesOwnedByThatCustomer() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
CustomerId customer = new CustomerId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
|
|||
stubDeviceOwner(tenant, device, customer); |
|||
when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); |
|||
|
|||
// tenant owns customer (getOwner for CUSTOMER returns tenantId)
|
|||
cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer}
|
|||
cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device}
|
|||
|
|||
assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer); |
|||
assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); |
|||
|
|||
// deleting the customer evicts the customer key and recursively cleans its owned set
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); |
|||
} |
|||
|
|||
@Test |
|||
public void evictOwner_tenantDeleted_recursivelyEvictsCustomerAndItsOwnedDevices() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
CustomerId customer = new CustomerId(UUID.randomUUID()); |
|||
DeviceId device = new DeviceId(UUID.randomUUID()); |
|||
|
|||
stubDeviceOwner(tenant, device, customer); |
|||
when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); |
|||
|
|||
cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer}
|
|||
cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device}
|
|||
|
|||
assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer); |
|||
assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); |
|||
|
|||
// deleting the tenant: evictOwner(tenant) finds customer (CUSTOMER type) and recurses into it
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// both levels must be gone
|
|||
assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(customer); |
|||
assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); |
|||
} |
|||
|
|||
// --- TenantProfile lifecycle tests ---
|
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantProfileUpdated_callsHandleTenantProfileUpdate() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
TenantProfileId profileId = new TenantProfileId(UUID.randomUUID()); |
|||
DefaultCalculatedFieldCache spyCache = spy(cache); |
|||
|
|||
spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
verify(spyCache).handleTenantProfileUpdate(profileId); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantProfileDeleted_doesNotCallHandleTenantProfileUpdate() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
TenantProfileId profileId = new TenantProfileId(UUID.randomUUID()); |
|||
DefaultCalculatedFieldCache spyCache = spy(cache); |
|||
|
|||
spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
verify(spyCache, never()).handleTenantProfileUpdate(any()); |
|||
} |
|||
|
|||
// --- Helpers ---
|
|||
|
|||
private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) { |
|||
Device device = new Device(); |
|||
device.setId(deviceId); |
|||
device.setTenantId(tenantId); |
|||
if (ownerId instanceof CustomerId customerId) { |
|||
device.setCustomerId(customerId); |
|||
} |
|||
// If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId
|
|||
when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); |
|||
// Stubs for getOwnedEntities iteration (empty pages — device is added explicitly)
|
|||
when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData()); |
|||
when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData()); |
|||
if (ownerId instanceof TenantId) { |
|||
when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); |
|||
} |
|||
} |
|||
|
|||
private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) { |
|||
CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); |
|||
CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig()); |
|||
when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf); |
|||
cache.addCalculatedField(tenantId, cfId); |
|||
return cf; |
|||
} |
|||
|
|||
private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId, EntityId linkedEntity) { |
|||
CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); |
|||
CalculatedFieldConfiguration config = linkedEntityCfConfig(tenantId, cfId, linkedEntity); |
|||
CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, config); |
|||
when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf); |
|||
cache.addCalculatedField(tenantId, cfId); |
|||
return cf; |
|||
} |
|||
|
|||
private CalculatedField buildCalculatedField(CalculatedFieldId id, TenantId tenantId, EntityId entityId, CalculatedFieldConfiguration config) { |
|||
CalculatedField cf = new CalculatedField(); |
|||
cf.setId(id); |
|||
cf.setTenantId(tenantId); |
|||
cf.setEntityId(entityId); |
|||
cf.setType(CalculatedFieldType.SIMPLE); |
|||
cf.setName("test-cf-" + id.getId()); |
|||
cf.setConfiguration(config); |
|||
return cf; |
|||
} |
|||
|
|||
private CalculatedFieldConfiguration simpleCfConfig() { |
|||
CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class); |
|||
when(config.getReferencedEntities()).thenReturn(Collections.emptySet()); |
|||
when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(Collections.emptyList()); |
|||
return config; |
|||
} |
|||
|
|||
private CalculatedFieldConfiguration linkedEntityCfConfig(TenantId tenantId, CalculatedFieldId cfId, EntityId linkedEntity) { |
|||
CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class); |
|||
CalculatedFieldLink link = new CalculatedFieldLink(tenantId, linkedEntity, cfId); |
|||
when(config.getReferencedEntities()).thenReturn(Set.of(linkedEntity)); |
|||
when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(List.of(link)); |
|||
when(config.buildCalculatedFieldLink(any(), eq(linkedEntity), any())).thenReturn(link); |
|||
return config; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,159 @@ |
|||
/** |
|||
* 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.service.profile; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.thingsboard.server.common.data.asset.Asset; |
|||
import org.thingsboard.server.common.data.asset.AssetProfile; |
|||
import org.thingsboard.server.common.data.id.AssetId; |
|||
import org.thingsboard.server.common.data.id.AssetProfileId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; |
|||
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; |
|||
import org.thingsboard.server.dao.asset.AssetProfileService; |
|||
import org.thingsboard.server.dao.asset.AssetService; |
|||
|
|||
import java.util.UUID; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class DefaultTbAssetProfileCacheTest { |
|||
|
|||
@Mock |
|||
private AssetProfileService assetProfileService; |
|||
@Mock |
|||
private AssetService assetService; |
|||
|
|||
private DefaultTbAssetProfileCache cache; |
|||
|
|||
@BeforeEach |
|||
public void setUp() { |
|||
cache = new DefaultTbAssetProfileCache(assetProfileService, assetService); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_evictsAssetProfilesForThatTenant() { |
|||
TenantId tenant1 = new TenantId(UUID.randomUUID()); |
|||
TenantId tenant2 = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); |
|||
AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); |
|||
|
|||
loadProfileIntoCache(tenant1, profileId1); |
|||
loadProfileIntoCache(tenant2, profileId2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// After deletion tenant1 profile should be reloaded from service on next get
|
|||
when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null); |
|||
assertThat(cache.get(tenant1, profileId1)).isNull(); |
|||
verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_evictsAssetMappingsForThatTenant() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); |
|||
AssetId assetId = new AssetId(UUID.randomUUID()); |
|||
|
|||
loadProfileIntoCache(tenant, profileId); |
|||
loadAssetMappingIntoCache(tenant, assetId, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// After tenant deletion, asset-to-profile mapping should be gone; get() should try to reload
|
|||
when(assetService.findAssetById(any(), any())).thenReturn(null); |
|||
assertThat(cache.get(tenant, assetId)).isNull(); |
|||
verify(assetService, times(2)).findAssetById(tenant, assetId); // once on load, once after eviction
|
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
EntityId listenerId = new AssetId(UUID.randomUUID()); |
|||
AtomicInteger callCount = new AtomicInteger(); |
|||
|
|||
cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// Evicting a profile after tenant deletion should not trigger the removed listener
|
|||
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); |
|||
loadProfileIntoCache(tenant, profileId); |
|||
cache.evict(tenant, profileId); |
|||
|
|||
assertThat(callCount.get()).isZero(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); |
|||
loadProfileIntoCache(tenant, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
// Profile should still be served from cache without hitting the service again
|
|||
cache.get(tenant, profileId); |
|||
verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { |
|||
TenantId tenant1 = new TenantId(UUID.randomUUID()); |
|||
TenantId tenant2 = new TenantId(UUID.randomUUID()); |
|||
AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); |
|||
AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); |
|||
|
|||
AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); |
|||
loadProfileIntoCache(tenant2, profileId2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); |
|||
verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); |
|||
} |
|||
|
|||
// --- Helpers ---
|
|||
|
|||
private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { |
|||
AssetProfile profile = new AssetProfile(); |
|||
profile.setId(profileId); |
|||
profile.setTenantId(tenantId); |
|||
when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); |
|||
cache.get(tenantId, profileId); |
|||
return profile; |
|||
} |
|||
|
|||
private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { |
|||
Asset asset = new Asset(); |
|||
asset.setId(assetId); |
|||
asset.setAssetProfileId(profileId); |
|||
when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); |
|||
cache.get(tenantId, assetId); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,160 @@ |
|||
/** |
|||
* 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.service.profile; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.Mock; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.thingsboard.server.common.data.Device; |
|||
import org.thingsboard.server.common.data.DeviceProfile; |
|||
import org.thingsboard.server.common.data.id.DeviceId; |
|||
import org.thingsboard.server.common.data.id.DeviceProfileId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; |
|||
import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; |
|||
import org.thingsboard.server.dao.device.DeviceProfileService; |
|||
import org.thingsboard.server.dao.device.DeviceService; |
|||
|
|||
import java.util.UUID; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.Mockito.times; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
@ExtendWith(MockitoExtension.class) |
|||
public class DefaultTbDeviceProfileCacheTest { |
|||
|
|||
@Mock |
|||
private DeviceProfileService deviceProfileService; |
|||
@Mock |
|||
private DeviceService deviceService; |
|||
|
|||
private DefaultTbDeviceProfileCache cache; |
|||
|
|||
@BeforeEach |
|||
public void setUp() { |
|||
cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { |
|||
TenantId tenant1 = new TenantId(UUID.randomUUID()); |
|||
TenantId tenant2 = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); |
|||
DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); |
|||
|
|||
loadProfileIntoCache(tenant1, profileId1); |
|||
loadProfileIntoCache(tenant2, profileId2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// After deletion tenant1 profile should be reloaded from service on next get
|
|||
when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); |
|||
assertThat(cache.get(tenant1, profileId1)).isNull(); |
|||
// tenant2 profile should still be served from cache (no extra service call)
|
|||
verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); |
|||
DeviceId deviceId = new DeviceId(UUID.randomUUID()); |
|||
|
|||
loadProfileIntoCache(tenant, profileId); |
|||
loadDeviceMappingIntoCache(tenant, deviceId, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// After tenant deletion, device-to-profile mapping should be gone; get() should try to reload
|
|||
when(deviceService.findDeviceById(any(), any())).thenReturn(null); |
|||
assertThat(cache.get(tenant, deviceId)).isNull(); |
|||
verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction
|
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
EntityId listenerId = new DeviceId(UUID.randomUUID()); |
|||
AtomicInteger callCount = new AtomicInteger(); |
|||
|
|||
cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
// Evicting a profile after tenant deletion should not trigger the removed listener
|
|||
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); |
|||
loadProfileIntoCache(tenant, profileId); |
|||
cache.evict(tenant, profileId); |
|||
|
|||
assertThat(callCount.get()).isZero(); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { |
|||
TenantId tenant = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); |
|||
loadProfileIntoCache(tenant, profileId); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); |
|||
|
|||
// Profile should still be served from cache without hitting the service again
|
|||
cache.get(tenant, profileId); |
|||
verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); |
|||
} |
|||
|
|||
@Test |
|||
public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { |
|||
TenantId tenant1 = new TenantId(UUID.randomUUID()); |
|||
TenantId tenant2 = new TenantId(UUID.randomUUID()); |
|||
DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); |
|||
DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); |
|||
|
|||
DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); |
|||
loadProfileIntoCache(tenant2, profileId2); |
|||
|
|||
cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); |
|||
|
|||
assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); |
|||
verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); |
|||
} |
|||
|
|||
// --- Helpers ---
|
|||
|
|||
private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { |
|||
DeviceProfile profile = new DeviceProfile(); |
|||
profile.setId(profileId); |
|||
profile.setTenantId(tenantId); |
|||
when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); |
|||
cache.get(tenantId, profileId); |
|||
return profile; |
|||
} |
|||
|
|||
private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { |
|||
Device device = new Device(); |
|||
device.setId(deviceId); |
|||
device.setDeviceProfileId(profileId); |
|||
when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); |
|||
cache.get(tenantId, deviceId); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,277 @@ |
|||
/** |
|||
* 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.service.ws; |
|||
|
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.test.util.ReflectionTestUtils; |
|||
import org.thingsboard.server.common.data.TenantProfile; |
|||
import org.thingsboard.server.common.data.id.CustomerId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.id.UserId; |
|||
import org.thingsboard.server.dao.attributes.AttributesService; |
|||
import org.thingsboard.server.dao.tenant.TbTenantProfileCache; |
|||
import org.thingsboard.server.dao.timeseries.TimeseriesService; |
|||
import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; |
|||
import org.thingsboard.server.service.security.AccessValidator; |
|||
import org.thingsboard.server.service.security.model.SecurityUser; |
|||
import org.thingsboard.server.service.security.model.UserPrincipal; |
|||
import org.thingsboard.server.service.subscription.TbEntityDataSubscriptionService; |
|||
import org.thingsboard.server.service.subscription.TbLocalSubscriptionService; |
|||
import org.thingsboard.server.service.ws.notification.NotificationCommandsHandler; |
|||
import org.thingsboard.server.service.ws.telemetry.cmd.v1.AttributesSubscriptionCmd; |
|||
|
|||
import java.util.Set; |
|||
import java.util.UUID; |
|||
import java.util.concurrent.ConcurrentMap; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.mockito.BDDMockito.willReturn; |
|||
import static org.mockito.Mockito.mock; |
|||
|
|||
class DefaultWebSocketServiceTest { |
|||
|
|||
DefaultWebSocketService service; |
|||
TbTenantProfileCache tenantProfileCache; |
|||
WebSocketMsgEndpoint msgEndpoint; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
tenantProfileCache = mock(TbTenantProfileCache.class); |
|||
msgEndpoint = mock(WebSocketMsgEndpoint.class); |
|||
|
|||
service = new DefaultWebSocketService( |
|||
mock(TbLocalSubscriptionService.class), |
|||
mock(TbEntityDataSubscriptionService.class), |
|||
mock(NotificationCommandsHandler.class), |
|||
msgEndpoint, |
|||
mock(AccessValidator.class), |
|||
mock(AttributesService.class), |
|||
mock(TimeseriesService.class), |
|||
mock(TbServiceInfoProvider.class), |
|||
tenantProfileCache |
|||
); |
|||
} |
|||
|
|||
// Regression test: publicUserSubscriptionsMap must be keyed by TenantId, not UserId(NULL_UUID).
|
|||
// With the old UserId(NULL_UUID) key, all tenants shared one global subscription counter.
|
|||
@Test |
|||
void processSubscription_publicUserSubscriptionsMap_isPerTenantNotGlobal() throws Exception { |
|||
int maxPublicSubscriptions = 2; |
|||
|
|||
TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile1 = new TenantProfile(); |
|||
profile1.createDefaultTenantProfileData(); |
|||
profile1.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile1).given(tenantProfileCache).get(tenant1); |
|||
|
|||
TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile2 = new TenantProfile(); |
|||
profile2.createDefaultTenantProfileData(); |
|||
profile2.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile2).given(tenantProfileCache).get(tenant2); |
|||
|
|||
// tenant1 fills up its quota
|
|||
for (int i = 0; i < maxPublicSubscriptions; i++) { |
|||
assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-" + i), subscriptionCmd(i))) |
|||
.as("tenant1 subscription %d should be accepted", i + 1) |
|||
.isTrue(); |
|||
} |
|||
|
|||
// tenant2 must have its own independent quota — this was the bug:
|
|||
// with UserId(NULL_UUID) as key all tenants shared one counter, so tenant2 would be blocked here
|
|||
for (int i = 0; i < maxPublicSubscriptions; i++) { |
|||
assertThat(service.processSubscription(mockPublicSessionRef(tenant2, "t2-session-" + i), subscriptionCmd(i))) |
|||
.as("tenant2 subscription %d should not be affected by tenant1's subscriptions", i + 1) |
|||
.isTrue(); |
|||
} |
|||
|
|||
// tenant1's (maxPublicSubscriptions + 1)-th subscription must be rejected
|
|||
assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-over"), subscriptionCmd(99))) |
|||
.as("tenant1 should be rejected after exceeding its limit") |
|||
.isFalse(); |
|||
|
|||
// Verify that publicUserSubscriptionsMap has separate entries per tenant
|
|||
@SuppressWarnings("unchecked") |
|||
ConcurrentMap<TenantId, Set<String>> publicUserSubscriptionsMap = |
|||
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); |
|||
|
|||
assertThat(publicUserSubscriptionsMap).as("map should contain tenant1").containsKey(tenant1); |
|||
assertThat(publicUserSubscriptionsMap).as("map should contain tenant2").containsKey(tenant2); |
|||
assertThat(publicUserSubscriptionsMap).as("map must not have a single NULL_UUID entry for all tenants") |
|||
.doesNotContainKey(new TenantId(EntityId.NULL_UUID)); |
|||
|
|||
assertThat(publicUserSubscriptionsMap.get(tenant1)) |
|||
.as("tenant1 should have exactly %d subscriptions", maxPublicSubscriptions) |
|||
.hasSize(maxPublicSubscriptions); |
|||
assertThat(publicUserSubscriptionsMap.get(tenant2)) |
|||
.as("tenant2 should have exactly %d subscriptions", maxPublicSubscriptions) |
|||
.hasSize(maxPublicSubscriptions); |
|||
} |
|||
|
|||
@Test |
|||
void processSubscription_publicUserSubscriptionsMap_subscriptionIdFormat() { |
|||
int maxPublicSubscriptions = 5; |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile = new TenantProfile(); |
|||
profile.createDefaultTenantProfileData(); |
|||
profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile).given(tenantProfileCache).get(tenantId); |
|||
|
|||
String sessionId = "my-session-id"; |
|||
int cmdId = 42; |
|||
WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); |
|||
service.processSubscription(sessionRef, subscriptionCmd(cmdId)); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
ConcurrentMap<TenantId, Set<String>> publicUserSubscriptionsMap = |
|||
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); |
|||
|
|||
Set<String> subs = publicUserSubscriptionsMap.get(tenantId); |
|||
assertThat(subs).hasSize(1); |
|||
assertThat(subs.iterator().next()).isEqualTo("[" + sessionId + "]:[" + cmdId + "]"); |
|||
} |
|||
|
|||
@Test |
|||
void processSubscription_unsubscribe_removesEntryFromPublicUserSubscriptionsMap() { |
|||
int maxPublicSubscriptions = 5; |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile = new TenantProfile(); |
|||
profile.createDefaultTenantProfileData(); |
|||
profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile).given(tenantProfileCache).get(tenantId); |
|||
|
|||
String sessionId = "session-1"; |
|||
int cmdId = 1; |
|||
WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); |
|||
|
|||
service.processSubscription(sessionRef, subscriptionCmd(cmdId)); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
ConcurrentMap<TenantId, Set<String>> publicUserSubscriptionsMap = |
|||
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); |
|||
assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(1); |
|||
|
|||
AttributesSubscriptionCmd unsubCmd = subscriptionCmd(cmdId); |
|||
unsubCmd.setUnsubscribe(true); |
|||
service.processSubscription(sessionRef, unsubCmd); |
|||
|
|||
assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void processSubscription_unsubscribe_freesSlotForNewSubscription() { |
|||
int maxPublicSubscriptions = 1; |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile = new TenantProfile(); |
|||
profile.createDefaultTenantProfileData(); |
|||
profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile).given(tenantProfileCache).get(tenantId); |
|||
|
|||
WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, "session-1"); |
|||
service.processSubscription(sessionRef, subscriptionCmd(1)); |
|||
|
|||
// slot is full — second subscription on same session should be rejected
|
|||
assertThat(service.processSubscription(sessionRef, subscriptionCmd(2))).isFalse(); |
|||
|
|||
// unsubscribe cmd 1 to free the slot
|
|||
AttributesSubscriptionCmd unsubCmd = subscriptionCmd(1); |
|||
unsubCmd.setUnsubscribe(true); |
|||
service.processSubscription(sessionRef, unsubCmd); |
|||
|
|||
// now a new subscription should succeed
|
|||
assertThat(service.processSubscription(sessionRef, subscriptionCmd(3))) |
|||
.as("new subscription should succeed after unsubscribe freed the slot") |
|||
.isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void processSessionClose_removesAllSessionSubscriptionsFromPublicUserSubscriptionsMap() { |
|||
int maxPublicSubscriptions = 10; |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile = new TenantProfile(); |
|||
profile.createDefaultTenantProfileData(); |
|||
profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile).given(tenantProfileCache).get(tenantId); |
|||
|
|||
String sessionId = "closing-session"; |
|||
WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); |
|||
|
|||
service.processSubscription(sessionRef, subscriptionCmd(1)); |
|||
service.processSubscription(sessionRef, subscriptionCmd(2)); |
|||
service.processSubscription(sessionRef, subscriptionCmd(3)); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
ConcurrentMap<TenantId, Set<String>> publicUserSubscriptionsMap = |
|||
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); |
|||
assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); |
|||
|
|||
service.processSessionClose(sessionRef); |
|||
|
|||
assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty(); |
|||
} |
|||
|
|||
@Test |
|||
void processSessionClose_onlyRemovesClosedSessionSubscriptions() { |
|||
int maxPublicSubscriptions = 10; |
|||
TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); |
|||
TenantProfile profile = new TenantProfile(); |
|||
profile.createDefaultTenantProfileData(); |
|||
profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); |
|||
willReturn(profile).given(tenantProfileCache).get(tenantId); |
|||
|
|||
WebSocketSessionRef session1 = mockPublicSessionRef(tenantId, "session-1"); |
|||
WebSocketSessionRef session2 = mockPublicSessionRef(tenantId, "session-2"); |
|||
|
|||
service.processSubscription(session1, subscriptionCmd(1)); |
|||
service.processSubscription(session1, subscriptionCmd(2)); |
|||
service.processSubscription(session2, subscriptionCmd(1)); |
|||
|
|||
@SuppressWarnings("unchecked") |
|||
ConcurrentMap<TenantId, Set<String>> publicUserSubscriptionsMap = |
|||
(ConcurrentMap<TenantId, Set<String>>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); |
|||
assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); |
|||
|
|||
service.processSessionClose(session1); |
|||
|
|||
Set<String> remaining = publicUserSubscriptionsMap.get(tenantId); |
|||
assertThat(remaining).hasSize(1); |
|||
assertThat(remaining).allMatch(subId -> subId.startsWith("[session-2]")); |
|||
} |
|||
|
|||
private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId, String sessionId) { |
|||
CustomerId customerId = new CustomerId(UUID.randomUUID()); |
|||
SecurityUser securityUser = mock(SecurityUser.class); |
|||
willReturn(tenantId).given(securityUser).getTenantId(); |
|||
willReturn(customerId).given(securityUser).getCustomerId(); |
|||
willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId(); |
|||
willReturn(true).given(securityUser).isCustomerUser(); |
|||
willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal(); |
|||
|
|||
WebSocketSessionRef ref = mock(WebSocketSessionRef.class); |
|||
willReturn(securityUser).given(ref).getSecurityCtx(); |
|||
willReturn(sessionId).given(ref).getSessionId(); |
|||
return ref; |
|||
} |
|||
|
|||
private AttributesSubscriptionCmd subscriptionCmd(int cmdId) { |
|||
AttributesSubscriptionCmd cmd = new AttributesSubscriptionCmd(); |
|||
cmd.setCmdId(cmdId); |
|||
return cmd; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,166 @@ |
|||
/** |
|||
* 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.annotation.JsonIgnore; |
|||
import com.fasterxml.jackson.annotation.JsonSetter; |
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import jakarta.validation.Valid; |
|||
import jakarta.validation.constraints.NotNull; |
|||
import lombok.Data; |
|||
import lombok.EqualsAndHashCode; |
|||
import org.thingsboard.server.common.data.BaseData; |
|||
import org.thingsboard.server.common.data.HasAdditionalInfo; |
|||
import org.thingsboard.server.common.data.HasDebugSettings; |
|||
import org.thingsboard.server.common.data.HasName; |
|||
import org.thingsboard.server.common.data.HasTenantId; |
|||
import org.thingsboard.server.common.data.HasVersion; |
|||
import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; |
|||
import org.thingsboard.server.common.data.debug.DebugSettings; |
|||
import org.thingsboard.server.common.data.id.CalculatedFieldId; |
|||
import org.thingsboard.server.common.data.id.EntityId; |
|||
import org.thingsboard.server.common.data.id.TenantId; |
|||
import org.thingsboard.server.common.data.validation.Length; |
|||
import org.thingsboard.server.common.data.validation.NoXss; |
|||
|
|||
@Schema |
|||
@Data |
|||
@EqualsAndHashCode(callSuper = true) |
|||
public class AlarmRuleDefinition extends BaseData<CalculatedFieldId> implements HasName, HasTenantId, HasVersion, HasDebugSettings, HasAdditionalInfo { |
|||
|
|||
private TenantId tenantId; |
|||
private EntityId entityId; |
|||
|
|||
@NoXss |
|||
@Length(fieldName = "name") |
|||
@Schema(description = "User defined name of the alarm rule.") |
|||
private String name; |
|||
@Deprecated |
|||
@Schema(description = "Enable/disable debug. ", example = "false", deprecated = true) |
|||
private boolean debugMode; |
|||
@Schema(description = "Debug settings object.") |
|||
private DebugSettings debugSettings; |
|||
@Schema(description = "Version of alarm rule configuration.", example = "0") |
|||
private int configurationVersion; |
|||
@Schema(implementation = AlarmCalculatedFieldConfiguration.class) |
|||
@Valid |
|||
@NotNull |
|||
private AlarmCalculatedFieldConfiguration configuration; |
|||
private Long version; |
|||
@NoXss |
|||
@Schema(description = "Additional parameters of the alarm rule. " + |
|||
"May include: 'description' (string).", |
|||
implementation = com.fasterxml.jackson.databind.JsonNode.class, |
|||
example = "{\"description\":\"High temperature alarm rule\"}") |
|||
private JsonNode additionalInfo; |
|||
|
|||
public AlarmRuleDefinition() {} |
|||
|
|||
public AlarmRuleDefinition(CalculatedFieldId id) { |
|||
super(id); |
|||
} |
|||
|
|||
public AlarmRuleDefinition(AlarmRuleDefinition alarmRuleDefinition) { |
|||
super(alarmRuleDefinition); |
|||
this.tenantId = alarmRuleDefinition.tenantId; |
|||
this.entityId = alarmRuleDefinition.entityId; |
|||
this.name = alarmRuleDefinition.name; |
|||
this.debugMode = alarmRuleDefinition.debugMode; |
|||
this.debugSettings = alarmRuleDefinition.debugSettings; |
|||
this.configurationVersion = alarmRuleDefinition.configurationVersion; |
|||
this.configuration = alarmRuleDefinition.configuration; |
|||
this.version = alarmRuleDefinition.version; |
|||
this.additionalInfo = alarmRuleDefinition.additionalInfo; |
|||
} |
|||
|
|||
@Schema(description = "JSON object with the Alarm Rule Id. Referencing non-existing Alarm Rule Id will cause error.") |
|||
@Override |
|||
public CalculatedFieldId getId() { |
|||
return super.getId(); |
|||
} |
|||
|
|||
@Schema(description = "Timestamp of the alarm rule creation, in milliseconds", example = "1609459200000", accessMode = Schema.AccessMode.READ_ONLY) |
|||
@Override |
|||
public long getCreatedTime() { |
|||
return super.getCreatedTime(); |
|||
} |
|||
|
|||
// Getter is ignored for serialization
|
|||
@JsonIgnore |
|||
public boolean isDebugMode() { |
|||
return debugMode; |
|||
} |
|||
|
|||
// Setter is annotated for deserialization
|
|||
@JsonSetter |
|||
public void setDebugMode(boolean debugMode) { |
|||
this.debugMode = debugMode; |
|||
} |
|||
|
|||
public CalculatedField toCalculatedField() { |
|||
CalculatedField cf = new CalculatedField(); |
|||
cf.setId(this.id); |
|||
cf.setCreatedTime(this.createdTime); |
|||
cf.setTenantId(this.tenantId); |
|||
cf.setEntityId(this.entityId); |
|||
cf.setType(CalculatedFieldType.ALARM); |
|||
cf.setName(this.name); |
|||
cf.setDebugMode(this.debugMode); |
|||
cf.setDebugSettings(this.debugSettings); |
|||
cf.setConfigurationVersion(this.configurationVersion); |
|||
cf.setConfiguration(this.configuration); |
|||
cf.setVersion(this.version); |
|||
cf.setAdditionalInfo(this.additionalInfo); |
|||
return cf; |
|||
} |
|||
|
|||
public static AlarmRuleDefinition fromCalculatedField(CalculatedField cf) { |
|||
AlarmRuleDefinition def = new AlarmRuleDefinition(); |
|||
def.setId(cf.getId()); |
|||
def.setCreatedTime(cf.getCreatedTime()); |
|||
def.setTenantId(cf.getTenantId()); |
|||
def.setEntityId(cf.getEntityId()); |
|||
def.setName(cf.getName()); |
|||
def.setDebugMode(cf.isDebugMode()); |
|||
def.setDebugSettings(cf.getDebugSettings()); |
|||
def.setConfigurationVersion(cf.getConfigurationVersion()); |
|||
if (!(cf.getConfiguration() instanceof AlarmCalculatedFieldConfiguration config)) { |
|||
throw new IllegalArgumentException("Expected ALARM calculated field, got " + cf.getType()); |
|||
} |
|||
def.setConfiguration(config); |
|||
def.setVersion(cf.getVersion()); |
|||
def.setAdditionalInfo(cf.getAdditionalInfo()); |
|||
return def; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
return new StringBuilder() |
|||
.append("AlarmRuleDefinition[") |
|||
.append("tenantId=").append(tenantId) |
|||
.append(", entityId=").append(entityId) |
|||
.append(", name='").append(name) |
|||
.append(", configurationVersion=").append(configurationVersion) |
|||
.append(", configuration=").append(configuration) |
|||
.append(", additionalInfo=").append(additionalInfo) |
|||
.append(", version=").append(version) |
|||
.append(", createdTime=").append(createdTime) |
|||
.append(", id=").append(id).append(']') |
|||
.toString(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,39 @@ |
|||
/** |
|||
* 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 lombok.Data; |
|||
import lombok.EqualsAndHashCode; |
|||
import lombok.NoArgsConstructor; |
|||
|
|||
@Data |
|||
@EqualsAndHashCode(callSuper = true) |
|||
@NoArgsConstructor |
|||
public class AlarmRuleDefinitionInfo extends AlarmRuleDefinition { |
|||
|
|||
private String entityName; |
|||
|
|||
public AlarmRuleDefinitionInfo(AlarmRuleDefinition alarmRuleDefinition, String entityName) { |
|||
super(alarmRuleDefinition); |
|||
this.entityName = entityName; |
|||
} |
|||
|
|||
public static AlarmRuleDefinitionInfo fromCalculatedFieldInfo(CalculatedFieldInfo cfi) { |
|||
AlarmRuleDefinition def = AlarmRuleDefinition.fromCalculatedField(cfi); |
|||
return new AlarmRuleDefinitionInfo(def, cfi.getEntityName()); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,125 @@ |
|||
/** |
|||
* 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. |
|||
*/ |
|||
syntax = "proto3"; |
|||
|
|||
option java_package = "org.thingsboard.server.gen.transport.coap"; |
|||
option java_outer_classname = "ConfigTypesProtos"; |
|||
|
|||
message ProtoCalibrationParametersRequest { |
|||
|
|||
/* Request details. * |
|||
* Bitmask: * |
|||
* - Bit 0:2 - Requested channel number, range: [1:6] * |
|||
* Status: Deprecated [06.10.00 - 06.xx.xx] */ |
|||
uint32 calibration_request = 1; |
|||
|
|||
/* Channel assignment - the sensor code. * |
|||
* Status: Deprecated [06.10.00 - 06.xx.xx] */ |
|||
uint32 channel_assignment = 2; |
|||
|
|||
/* Channel calibration parameters. * |
|||
* Up to 8 parameters supported. * |
|||
* If this field is empty, the sensor will send the current set of parameters. * |
|||
* Status: Deprecated [06.10.00 - 06.xx.xx] */ |
|||
repeated int32 parameters = 3; |
|||
} |
|||
|
|||
message ProtoOutputControlState { |
|||
|
|||
/* Channel index. * |
|||
* Range: [0:5] * |
|||
* Status: In use [06.13.00/06.21.00 - LATEST] */ |
|||
uint32 channel_index = 1; |
|||
|
|||
/* Channel output state: * |
|||
* - 1 - OFF * |
|||
* - 2 - ON * |
|||
* Status: In use [06.13.00/06.21.00 - LATEST] */ |
|||
uint32 channel_state = 2; |
|||
} |
|||
|
|||
enum BleAdvertisingPeriodMode { |
|||
|
|||
/* Invalid value. */ |
|||
BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; |
|||
|
|||
/* Default mode. * |
|||
* Bluetooth advertising interval is set to 1022.5ms or a lower value, based on the continuous measurement period. */ |
|||
BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; |
|||
|
|||
/* Normal mode. * |
|||
* Uses the value configured by the user from the 'normal' field. */ |
|||
BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; |
|||
|
|||
/* Fast mode. * |
|||
* Uses the value configured by the user from the 'fast' field. */ |
|||
BLE_ADVERTISING_PERIOD_MODE_FAST = 3; |
|||
} |
|||
|
|||
message ProtoBleAdvertisingPeriod { |
|||
|
|||
/* Bluetooth advertising mode. * |
|||
* Status: In use [06.13.00/06.21.00 - LATEST] */ |
|||
BleAdvertisingPeriodMode mode = 1; |
|||
|
|||
/* Bluetooth advertising interval in normal mode, configured in steps of 0.625 ms. * |
|||
* Range: [32:16384] * |
|||
* Status: In use [06.13.00/06.21.00 - LATEST] */ |
|||
uint32 normal = 2; |
|||
|
|||
/* Bluetooth advertising interval in fast mode, configured in steps of 0.625 ms. * |
|||
* Range: [32:16384] * |
|||
* Status: In use [06.13.00/06.21.00 - LATEST] */ |
|||
uint32 fast = 3; |
|||
} |
|||
|
|||
enum AdvertisementManufacturerDataFormat { |
|||
|
|||
/* Invalid value. */ |
|||
ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_UNSPECIFIED = 0; |
|||
|
|||
/* Advertisement manufacturer specific data format 3. */ |
|||
ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V3 = 1; |
|||
|
|||
/* Advertisement manufacturer specific data format 5. */ |
|||
ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V5 = 2; |
|||
} |
|||
|
|||
message ProtoNetworkSearch { |
|||
|
|||
/* Timing schema, if successful registration since the last reset. * |
|||
* Length: 6 items. * |
|||
* 1st - 6th item - Time in minutes. Range: [1:255]. * |
|||
* Status: In use [06.20.00 - LATEST] */ |
|||
repeated uint32 time_schema_last_registration_ok = 1; |
|||
|
|||
/* Timing schema, if no successful registration since the last reset. * |
|||
* Length: 6 items. * |
|||
* 1st - 6th item - Time in minutes. Range: [1:255]. * |
|||
* Status: In use [06.20.00 - LATEST] */ |
|||
repeated uint32 time_schema_last_registration_not_ok = 2; |
|||
|
|||
/* Disable base period in minutes. * |
|||
* Disable time = 'disable_period_base' * counter (from 1 to 'counter_max'). * |
|||
* Range: [1:255] * |
|||
* Status: In use [06.20.00 - LATEST] */ |
|||
uint32 disable_period_base = 3; |
|||
|
|||
/* Disable counter maximum. * |
|||
* Range: [1:255] * |
|||
* Status: In use [06.20.00 - LATEST] */ |
|||
uint32 counter_max = 4; |
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue